use std::fmt;
use std::time::{Duration, Instant};
use serde::Deserialize;
use crate::TastyTradeError;
use crate::api::base::TastyResult;
use crate::error::Environment;
const REDACTED: &str = "***";
const AUTHORIZE_URL: &str = "https://my.tastytrade.com/auth.html";
const AUTHORIZE_DEMO_URL: &str = "https://cert-my.staging-tasty.works/auth.html";
pub(crate) const REFRESH_MARGIN: Duration = Duration::from_secs(60);
const DEFAULT_TOKEN_LIFETIME: Duration = Duration::from_secs(15 * 60);
const MAX_TOKEN_LIFETIME: Duration = Duration::from_secs(24 * 60 * 60);
macro_rules! secret_string {
($(#[$attr:meta])* $name:ident) => {
$(#[$attr])*
#[derive(Clone, Default, PartialEq, Eq, Deserialize)]
#[serde(transparent)]
pub struct $name(String);
impl $name {
pub fn new(value: impl Into<String>) -> Self {
Self(value.into())
}
pub fn expose_secret(&self) -> &str {
&self.0
}
pub fn is_blank(&self) -> bool {
self.0.trim().is_empty()
}
}
impl From<String> for $name {
fn from(value: String) -> Self {
Self(value)
}
}
impl From<&str> for $name {
fn from(value: &str) -> Self {
Self(value.to_string())
}
}
impl fmt::Debug for $name {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}({REDACTED})", stringify!($name))
}
}
impl fmt::Display for $name {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(REDACTED)
}
}
};
}
secret_string! {
ClientSecret
}
secret_string! {
RefreshToken
}
secret_string! {
AccessToken
}
secret_string! {
AuthorizationCode
}
secret_string! {
IdToken
}
impl AccessToken {
pub fn bearer(&self) -> String {
format!("Bearer {}", self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Scope {
Read,
Trade,
OpenId,
}
impl Scope {
pub fn as_str(&self) -> &'static str {
match self {
Scope::Read => "read",
Scope::Trade => "trade",
Scope::OpenId => "openid",
}
}
}
impl fmt::Display for Scope {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone)]
pub struct AuthorizationRequest {
pub client_id: String,
pub redirect_uri: String,
pub scopes: Vec<Scope>,
pub state: Option<String>,
}
impl AuthorizationRequest {
pub fn new(client_id: impl Into<String>, redirect_uri: impl Into<String>) -> Self {
Self {
client_id: client_id.into(),
redirect_uri: redirect_uri.into(),
scopes: Vec::new(),
state: None,
}
}
#[must_use]
pub fn with_scopes(mut self, scopes: impl IntoIterator<Item = Scope>) -> Self {
self.scopes = scopes.into_iter().collect();
self
}
#[must_use]
pub fn with_state(mut self, state: impl Into<String>) -> Self {
self.state = Some(state.into());
self
}
pub fn authorize_url(&self, environment: Environment) -> TastyResult<String> {
if self.client_id.trim().is_empty() {
return Err(TastyTradeError::Precondition(
"an authorization request needs a client id".to_string(),
));
}
if self.redirect_uri.trim().is_empty() {
return Err(TastyTradeError::Precondition(
"an authorization request needs a redirect URI registered with tastytrade"
.to_string(),
));
}
let host = match environment {
Environment::Production => AUTHORIZE_URL,
Environment::Certification => AUTHORIZE_DEMO_URL,
};
let mut params: Vec<(&str, String)> = vec![
("client_id", self.client_id.clone()),
("redirect_uri", self.redirect_uri.clone()),
("response_type", "code".to_string()),
];
if !self.scopes.is_empty() {
let scopes = self
.scopes
.iter()
.map(Scope::as_str)
.collect::<Vec<_>>()
.join(" ");
params.push(("scope", scopes));
}
if let Some(state) = &self.state {
params.push(("state", state.clone()));
}
reqwest::Url::parse_with_params(host, ¶ms)
.map(|url| url.to_string())
.map_err(|e| {
TastyTradeError::Precondition(format!("could not build the authorization URL: {e}"))
})
}
pub fn verify_state(&self, returned: Option<&str>) -> TastyResult<()> {
match (&self.state, returned) {
(None, _) => Ok(()),
(Some(expected), Some(actual)) if expected == actual => Ok(()),
(Some(_), Some(_)) => Err(TastyTradeError::Precondition(
"the authorization response carried a different state than the request; \
discard the code rather than exchanging it"
.to_string(),
)),
(Some(_), None) => Err(TastyTradeError::Precondition(
"the authorization response carried no state, but the request sent one; \
discard the code rather than exchanging it"
.to_string(),
)),
}
}
}
#[derive(Debug, Clone)]
pub enum OAuthGrant {
Refresh {
client_secret: ClientSecret,
refresh_token: RefreshToken,
},
AuthorizationCode {
code: AuthorizationCode,
client_id: String,
client_secret: ClientSecret,
redirect_uri: String,
},
}
impl OAuthGrant {
pub(crate) fn form_parameters(&self) -> Vec<(&'static str, &str)> {
match self {
OAuthGrant::Refresh {
client_secret,
refresh_token,
} => vec![
("grant_type", "refresh_token"),
("refresh_token", refresh_token.expose_secret()),
("client_secret", client_secret.expose_secret()),
],
OAuthGrant::AuthorizationCode {
code,
client_id,
client_secret,
redirect_uri,
} => vec![
("grant_type", "authorization_code"),
("code", code.expose_secret()),
("client_id", client_id.as_str()),
("client_secret", client_secret.expose_secret()),
("redirect_uri", redirect_uri.as_str()),
],
}
}
pub(crate) fn grant_type(&self) -> &'static str {
match self {
OAuthGrant::Refresh { .. } => "refresh_token",
OAuthGrant::AuthorizationCode { .. } => "authorization_code",
}
}
}
#[derive(Deserialize)]
pub struct TokenResponse {
pub access_token: AccessToken,
#[serde(default)]
pub refresh_token: Option<RefreshToken>,
#[serde(default)]
pub token_type: Option<String>,
#[serde(default)]
pub expires_in: Option<u64>,
#[serde(default)]
pub id_token: Option<IdToken>,
}
impl TokenResponse {
pub fn lifetime(&self) -> Duration {
self.expires_in
.map(Duration::from_secs)
.map(|lifetime| lifetime.min(MAX_TOKEN_LIFETIME))
.unwrap_or(DEFAULT_TOKEN_LIFETIME)
}
}
impl fmt::Debug for TokenResponse {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("TokenResponse")
.field("access_token", &REDACTED)
.field(
"refresh_token",
&self.refresh_token.as_ref().map(|_| REDACTED),
)
.field("token_type", &self.token_type)
.field("expires_in", &self.expires_in)
.field("id_token", &self.id_token.as_ref().map(|_| REDACTED))
.finish()
}
}
impl fmt::Display for TokenResponse {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{self:?}")
}
}
#[derive(Debug, Clone)]
pub(crate) struct ActiveToken {
pub(crate) token: AccessToken,
expires_at: Instant,
}
impl ActiveToken {
pub(crate) fn new(token: AccessToken, lifetime: Duration) -> Self {
let expires_at = Instant::now()
.checked_add(lifetime)
.unwrap_or_else(|| Instant::now() + DEFAULT_TOKEN_LIFETIME);
Self { token, expires_at }
}
pub(crate) fn is_stale(&self) -> bool {
self.remaining().map(|left| left <= REFRESH_MARGIN) != Some(false)
}
pub(crate) fn remaining(&self) -> Option<Duration> {
self.expires_at.checked_duration_since(Instant::now())
}
}
const SPECIFIED_ERROR_CODES: [&str; 10] = [
"invalid_request",
"invalid_client",
"invalid_grant",
"unauthorized_client",
"unsupported_grant_type",
"invalid_scope",
"access_denied",
"unsupported_response_type",
"server_error",
"temporarily_unavailable",
];
const UNRECOGNISED_ERROR_CODE: &str = "an unrecognised error code";
#[derive(Deserialize)]
pub(crate) struct TokenErrorResponse {
error: String,
}
impl TokenErrorResponse {
pub(crate) fn code(&self) -> &'static str {
SPECIFIED_ERROR_CODES
.into_iter()
.find(|known| *known == self.error.trim())
.unwrap_or(UNRECOGNISED_ERROR_CODE)
}
pub(crate) fn is_credential_failure(&self) -> bool {
matches!(
self.code(),
"invalid_grant" | "invalid_client" | "unauthorized_client" | "access_denied"
)
}
}
impl fmt::Debug for TokenErrorResponse {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("TokenErrorResponse")
.field("error", &self.code())
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
const SECRET: &str = "SENTINEL-client-secret-3Qv7";
const REFRESH: &str = "SENTINEL-refresh-token-8Hb2";
const ACCESS: &str = "SENTINEL-access-token-5Nd9";
#[test]
fn secrets_never_render_themselves() {
let rendered = format!(
"{:?} {} {:?} {} {:?} {} {:?} {:?}",
ClientSecret::new(SECRET),
ClientSecret::new(SECRET),
RefreshToken::new(REFRESH),
RefreshToken::new(REFRESH),
AccessToken::new(ACCESS),
AccessToken::new(ACCESS),
AuthorizationCode::new("SENTINEL-code-1Ww4"),
IdToken::new("SENTINEL-id-token-2Ee5"),
);
for secret in [
SECRET,
REFRESH,
ACCESS,
"SENTINEL-code-1Ww4",
"SENTINEL-id-token-2Ee5",
] {
assert!(!rendered.contains(secret), "{secret} leaked: {rendered}");
}
assert!(rendered.contains(REDACTED), "{rendered}");
}
#[test]
fn a_token_response_redacts_every_token() {
let response = TokenResponse {
access_token: AccessToken::new(ACCESS),
refresh_token: Some(RefreshToken::new(REFRESH)),
token_type: Some("Bearer".to_string()),
expires_in: Some(900),
id_token: Some(IdToken::new("SENTINEL-id-token-2Ee5")),
};
for rendered in [format!("{response:?}"), format!("{response}")] {
for secret in [ACCESS, REFRESH, "SENTINEL-id-token-2Ee5"] {
assert!(!rendered.contains(secret), "{secret} leaked: {rendered}");
}
assert!(rendered.contains("900"), "{rendered}");
assert!(rendered.contains("Bearer"), "{rendered}");
}
}
#[test]
fn a_missing_expires_in_falls_back_to_the_documented_lifetime() {
let response = TokenResponse {
access_token: AccessToken::new(ACCESS),
refresh_token: None,
token_type: None,
expires_in: None,
id_token: None,
};
assert_eq!(response.lifetime(), DEFAULT_TOKEN_LIFETIME);
let response = TokenResponse {
expires_in: Some(42),
..response
};
assert_eq!(response.lifetime(), Duration::from_secs(42));
}
#[test]
fn the_header_value_carries_the_bearer_prefix() {
assert_eq!(AccessToken::new("abc").bearer(), "Bearer abc");
}
#[test]
fn the_authorization_url_carries_no_secret_and_encodes_its_parameters() {
let request =
AuthorizationRequest::new("client-abc", "https://app.example.com/cb?flow=a b")
.with_scopes([Scope::Read, Scope::Trade])
.with_state("state-xyz");
let url = request
.authorize_url(Environment::Production)
.expect("a complete request builds a URL");
assert!(url.starts_with(AUTHORIZE_URL), "{url}");
assert!(url.contains("response_type=code"), "{url}");
assert!(url.contains("client_id=client-abc"), "{url}");
assert!(url.contains("state=state-xyz"), "{url}");
assert!(
url.contains("redirect_uri=https%3A%2F%2Fapp.example.com%2Fcb%3Fflow%3Da+b"),
"{url}"
);
assert!(url.contains("scope=read+trade"), "{url}");
assert!(!url.contains("client_secret"), "{url}");
let sandbox = request
.authorize_url(Environment::Certification)
.expect("a complete request builds a URL");
assert!(sandbox.starts_with(AUTHORIZE_DEMO_URL), "{sandbox}");
}
#[test]
fn an_incomplete_authorization_request_fails_before_a_customer_is_sent_anywhere() {
let error = AuthorizationRequest::new(" ", "https://app.example.com/cb")
.authorize_url(Environment::Certification)
.expect_err("a blank client id is not a request");
assert!(
matches!(error, TastyTradeError::Precondition(_)),
"{error:?}"
);
let error = AuthorizationRequest::new("client-abc", "")
.authorize_url(Environment::Certification)
.expect_err("a blank redirect URI is not a request");
assert!(
matches!(error, TastyTradeError::Precondition(_)),
"{error:?}"
);
}
#[test]
fn the_state_has_to_come_back_exactly() {
let request =
AuthorizationRequest::new("client-abc", "https://app.example.com/cb").with_state("s1");
assert!(request.verify_state(Some("s1")).is_ok());
assert!(request.verify_state(Some("s2")).is_err());
assert!(request.verify_state(None).is_err());
let stateless = AuthorizationRequest::new("client-abc", "https://app.example.com/cb");
assert!(stateless.verify_state(None).is_ok());
assert!(stateless.verify_state(Some("anything")).is_ok());
}
#[test]
fn a_grant_renders_its_parameters_without_naming_them_in_its_type() {
let refresh = OAuthGrant::Refresh {
client_secret: ClientSecret::new(SECRET),
refresh_token: RefreshToken::new(REFRESH),
};
assert_eq!(refresh.grant_type(), "refresh_token");
let params = refresh.form_parameters();
assert!(params.contains(&("grant_type", "refresh_token")));
assert!(params.contains(&("refresh_token", REFRESH)));
assert!(params.contains(&("client_secret", SECRET)));
let rendered = format!("{refresh:?}");
assert!(!rendered.contains(SECRET), "{rendered}");
assert!(!rendered.contains(REFRESH), "{rendered}");
let code = OAuthGrant::AuthorizationCode {
code: AuthorizationCode::new("code-1"),
client_id: "client-abc".to_string(),
client_secret: ClientSecret::new(SECRET),
redirect_uri: "https://app.example.com/cb".to_string(),
};
assert_eq!(code.grant_type(), "authorization_code");
let params = code.form_parameters();
assert!(params.contains(&("grant_type", "authorization_code")));
assert!(params.contains(&("code", "code-1")));
assert!(params.contains(&("redirect_uri", "https://app.example.com/cb")));
}
#[test]
fn a_token_inside_the_margin_is_already_stale() {
let fresh = ActiveToken::new(AccessToken::new(ACCESS), Duration::from_secs(900));
assert!(!fresh.is_stale());
assert!(fresh.remaining().is_some());
let expiring = ActiveToken::new(AccessToken::new(ACCESS), REFRESH_MARGIN);
assert!(
expiring.is_stale(),
"a token with only the margin left must be refreshed before it is used"
);
let expired = ActiveToken::new(AccessToken::new(ACCESS), Duration::ZERO);
assert!(expired.is_stale());
assert_eq!(expired.remaining(), None);
}
#[test]
fn only_credential_refusals_are_terminal() {
for code in [
"invalid_grant",
"invalid_client",
"unauthorized_client",
"access_denied",
] {
assert!(
refusal(code).is_credential_failure(),
"{code} is about the credential"
);
}
for code in ["invalid_request", "server_error", "temporarily_unavailable"] {
assert!(
!refusal(code).is_credential_failure(),
"{code} is not about the credential"
);
}
}
fn refusal(code: &str) -> TokenErrorResponse {
serde_json::from_str(&format!(r#"{{"error":"{code}"}}"#)).expect("a refusal document")
}
#[test]
fn an_error_code_the_spec_does_not_define_never_travels() {
let refusal = refusal(SECRET);
assert_eq!(
refusal.code(),
UNRECOGNISED_ERROR_CODE,
"only the spec's own codes may leave this type"
);
assert!(
!format!("{refusal:?}").contains(SECRET),
"Debug rendered the field: {refusal:?}"
);
assert!(!refusal.is_credential_failure());
}
#[test]
fn a_specified_error_code_survives_intact() {
for code in SPECIFIED_ERROR_CODES {
assert_eq!(refusal(code).code(), code);
assert_eq!(refusal(&format!(" {code} ")).code(), code);
}
}
#[test]
fn an_absurd_lifetime_is_clamped_rather_than_panicking() {
let response = TokenResponse {
access_token: AccessToken::new(ACCESS),
refresh_token: None,
token_type: None,
expires_in: Some(u64::MAX),
id_token: None,
};
assert_eq!(
response.lifetime(),
MAX_TOKEN_LIFETIME,
"an unbounded lifetime is not a lifetime"
);
let token = ActiveToken::new(AccessToken::new(ACCESS), Duration::from_secs(u64::MAX));
assert!(!token.is_stale(), "the fallback still has to be usable");
assert!(
ActiveToken::new(AccessToken::new(ACCESS), response.lifetime())
.remaining()
.is_some()
);
}
#[test]
fn a_secret_deserializes_transparently() {
let secret: ClientSecret =
serde_json::from_str("\"SENTINEL-client-secret-3Qv7\"").expect("a plain JSON string");
assert_eq!(secret.expose_secret(), SECRET);
assert!(!secret.is_blank());
assert!(ClientSecret::new(" \t ").is_blank());
}
}