use std::fmt::{Debug, Display, Formatter, Result as FmtResult};
use std::hash::{Hash, Hasher};
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use secrecy::{ExposeSecret as _, SecretString};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::{CredentialError, CredentialResult};
const EXPIRY_MARGIN: Duration = Duration::from_secs(30);
const REFRESH_MARGIN: Duration = Duration::from_secs(60);
#[derive(Clone)]
pub struct Token {
inner: Arc<TokenInner>,
}
struct TokenInner {
value: SecretString,
resource: Option<String>,
}
impl Token {
pub fn expose_secret(&self) -> &str {
self.inner.value.expose_secret()
}
pub fn resource(&self) -> Option<&str> {
self.inner.resource.as_deref()
}
pub fn with_resource(self, resource: Option<String>) -> Self {
Self {
inner: Arc::new(TokenInner {
value: self.inner.value.clone(),
resource,
}),
}
}
pub fn unverified_claims<T: DeserializeOwned>(&self) -> CredentialResult<T> {
let mut segments = self.expose_secret().split('.');
let (Some(_header), Some(payload), Some(_signature), None) = (
segments.next(),
segments.next(),
segments.next(),
segments.next(),
) else {
return Err(CredentialError::invalid("the token is not a JWT"));
};
let payload = URL_SAFE_NO_PAD
.decode(payload.trim_end_matches('='))
.map_err(|error| CredentialError::invalid(format!("the JWT payload: {error}")))?;
Ok(serde_json::from_slice(&payload)?)
}
pub fn remaining_lifetime(&self) -> CredentialResult<Duration> {
let Expiry { exp } = self.unverified_claims()?;
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("the clock is after the Unix epoch")
.as_secs();
Ok(Duration::from_secs(exp.saturating_sub(now)))
}
pub fn is_expired(&self) -> CredentialResult<bool> {
Ok(self.remaining_lifetime()? < EXPIRY_MARGIN)
}
pub fn needs_refresh(&self) -> CredentialResult<bool> {
Ok(self.remaining_lifetime()? < REFRESH_MARGIN)
}
}
struct Expiry {
exp: u64,
}
impl<'de> Deserialize<'de> for Expiry {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
#[derive(Deserialize)]
struct Claims {
exp: serde_json::Number,
}
let Claims { exp } = Claims::deserialize(deserializer)?;
let exp = exp
.as_u64()
.or_else(|| {
exp.as_f64()
.filter(|seconds| seconds.is_finite() && *seconds >= 0.0)
.map(|seconds| seconds.round() as u64)
})
.ok_or_else(|| serde::de::Error::custom("exp must be a non-negative number"))?;
Ok(Self { exp })
}
}
impl From<String> for Token {
fn from(value: String) -> Self {
Self {
inner: Arc::new(TokenInner {
value: SecretString::from(value),
resource: None,
}),
}
}
}
impl From<&str> for Token {
fn from(value: &str) -> Self {
Self::from(value.to_owned())
}
}
impl PartialEq for Token {
fn eq(&self, other: &Self) -> bool {
self.expose_secret() == other.expose_secret()
}
}
impl Eq for Token {}
impl Hash for Token {
fn hash<H: Hasher>(&self, state: &mut H) {
self.expose_secret().hash(state);
}
}
impl Serialize for Token {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(self.expose_secret())
}
}
impl<'de> Deserialize<'de> for Token {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
String::deserialize(deserializer).map(Self::from)
}
}
impl Display for Token {
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
f.write_str(&"*".repeat(16))
}
}
impl Debug for Token {
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
f.debug_struct("Token")
.field("value", &"*".repeat(16))
.finish()
}
}
#[cfg(test)]
pub(crate) fn unsigned_jwt(claims: serde_json::Value) -> Token {
let header = URL_SAFE_NO_PAD.encode(br#"{"alg":"none"}"#);
let payload = URL_SAFE_NO_PAD.encode(claims.to_string());
Token::from(format!("{header}.{payload}.signature"))
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
fn now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
}
#[test]
fn remaining_lifetime_tracks_the_exp_claim() {
let remaining = unsigned_jwt(json!({ "exp": now() + 600 }))
.remaining_lifetime()
.unwrap();
assert!(
remaining > Duration::from_secs(598) && remaining <= Duration::from_secs(600),
"unexpected remaining lifetime: {remaining:?}"
);
}
#[test]
fn reads_a_decimal_exp_and_expires_past_tokens() {
let token = unsigned_jwt(json!({ "exp": 1_000_000_000.0 }));
assert_eq!(token.remaining_lifetime().unwrap(), Duration::ZERO);
assert!(token.is_expired().unwrap());
}
#[test]
fn rejects_a_token_that_is_not_a_jwt() {
assert!(Token::from("not-a-jwt").remaining_lifetime().is_err());
}
#[test]
fn serializes_as_its_value_and_redacts_debug_output() {
let token: Token = serde_json::from_value(json!("very_secret")).unwrap();
assert_eq!(serde_json::to_value(&token).unwrap(), json!("very_secret"));
assert!(!format!("{token:?}").contains("very_secret"));
}
}