use serde::{Deserialize, Serialize};
use std::time::{Duration, SystemTime};
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TokenResponse {
pub access_token: String,
pub token_type: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires_in: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub refresh_token: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scope: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id_token: Option<String>,
}
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TokenSet {
pub access_token: String,
pub token_type: String,
pub refresh_token: Option<String>,
pub scope: Option<String>,
pub id_token: Option<String>,
pub expires_at: Option<SystemTime>,
}
impl TokenSet {
#[inline]
pub fn is_expired(&self) -> bool {
self.expires_within(Duration::ZERO)
}
pub fn expires_within(&self, leeway: Duration) -> bool {
self.expires_at.is_some_and(|expires_at| {
SystemTime::now()
.checked_add(leeway)
.is_none_or(|deadline| deadline >= expires_at)
})
}
}
impl From<TokenResponse> for TokenSet {
fn from(response: TokenResponse) -> Self {
Self {
access_token: response.access_token,
token_type: response.token_type,
refresh_token: response.refresh_token,
scope: response.scope,
id_token: response.id_token,
expires_at: response
.expires_in
.and_then(|secs| SystemTime::now().checked_add(Duration::from_secs(secs))),
}
}
}
impl std::fmt::Debug for TokenResponse {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TokenResponse")
.field("access_token", &"[redacted]")
.field("token_type", &self.token_type)
.field("expires_in", &self.expires_in)
.field(
"refresh_token",
&self.refresh_token.as_ref().map(|_| "[redacted]"),
)
.field("scope", &self.scope)
.field("id_token", &self.id_token.as_ref().map(|_| "[redacted]"))
.finish()
}
}
impl std::fmt::Debug for TokenSet {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TokenSet")
.field("access_token", &"[redacted]")
.field("token_type", &self.token_type)
.field(
"refresh_token",
&self.refresh_token.as_ref().map(|_| "[redacted]"),
)
.field("scope", &self.scope)
.field("id_token", &self.id_token.as_ref().map(|_| "[redacted]"))
.field("expires_at", &self.expires_at)
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn response(expires_in: Option<u64>) -> TokenResponse {
TokenResponse {
access_token: "at".into(),
token_type: "Bearer".into(),
expires_in,
refresh_token: Some("rt".into()),
scope: Some("read".into()),
id_token: None,
}
}
#[test]
fn it_deserializes_a_minimal_response() {
let response: TokenResponse =
serde_json::from_str(r#"{"access_token": "at", "token_type": "Bearer"}"#).unwrap();
assert_eq!(response.access_token, "at");
assert_eq!(response.expires_in, None);
assert_eq!(response.refresh_token, None);
}
#[test]
fn it_resolves_expiration_into_absolute_time() {
let tokens = TokenSet::from(response(Some(3600)));
let expires_at = tokens.expires_at.unwrap();
let lifetime = expires_at.duration_since(SystemTime::now()).unwrap();
assert!(lifetime > Duration::from_secs(3590) && lifetime <= Duration::from_secs(3600));
assert!(!tokens.is_expired());
assert!(tokens.expires_within(Duration::from_secs(3601)));
let tokens = TokenSet::from(response(None));
assert!(!tokens.is_expired());
assert!(!tokens.expires_within(Duration::from_secs(3600)));
let tokens = TokenSet::from(response(Some(0)));
assert!(tokens.is_expired());
}
#[test]
fn it_survives_unrepresentable_lifetimes() {
let tokens = TokenSet::from(response(Some(u64::MAX)));
assert_eq!(tokens.expires_at, None);
assert!(!tokens.is_expired());
let tokens = TokenSet::from(response(Some(3600)));
assert!(tokens.expires_within(Duration::MAX));
let tokens = TokenSet::from(response(None));
assert!(!tokens.expires_within(Duration::MAX));
}
#[test]
fn it_redacts_tokens_in_debug_output() {
let debug = format!("{:?}", TokenSet::from(response(Some(60))));
assert!(!debug.contains("at") || debug.contains("[redacted]"));
assert!(!debug.contains("\"rt\""));
let debug = format!("{:?}", response(Some(60)));
assert!(debug.contains("[redacted]"));
}
}