spiral-auth 0.1.12

Spiral credentials: bearer tokens and the providers that obtain them
Documentation
//! Bearer tokens and their unverified claims.

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};

/// A token within this lifetime of its expiry is unusable.
const EXPIRY_MARGIN: Duration = Duration::from_secs(30);
/// A token within this lifetime of its expiry should be replaced before its next use.
const REFRESH_MARGIN: Duration = Duration::from_secs(60);

/// A bearer token, shared behind one pointer so that it stays cheap to clone and embed.
#[derive(Clone)]
pub struct Token {
    inner: Arc<TokenInner>,
}

struct TokenInner {
    value: SecretString,
    /// The resource the token is for, as its token response named it. Not part of the token
    /// itself, so neither compared nor serialized.
    resource: Option<String>,
}

impl Token {
    /// The token itself, to send as a bearer credential or store.
    pub fn expose_secret(&self) -> &str {
        self.inner.value.expose_secret()
    }

    /// The resource the token is for, such as the API URL to call, when its token response
    /// named one. Clients read this rather than the token's claims (RFC 9068 §6).
    pub fn resource(&self) -> Option<&str> {
        self.inner.resource.as_deref()
    }

    /// Record the resource that the token's response named.
    pub fn with_resource(self, resource: Option<String>) -> Self {
        Self {
            inner: Arc::new(TokenInner {
                value: self.inner.value.clone(),
                resource,
            }),
        }
    }

    /// Decode the claims of a JWT without verifying its signature.
    ///
    /// Use the claims only to manage the token, such as deciding when to refresh it; the
    /// service that accepts the token is the one that verifies it.
    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"));
        };
        // RFC 7515 §2: JWS segments are base64url without padding.
        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)?)
    }

    /// How long until the token expires, without validating its signature.
    ///
    /// Zero once expired, rather than underflowing.
    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)))
    }

    /// True if the token has expired or will within 30 seconds. Such a token is unusable.
    pub fn is_expired(&self) -> CredentialResult<bool> {
        Ok(self.remaining_lifetime()? < EXPIRY_MARGIN)
    }

    /// True if the token expires within a minute. It is still usable, but a fresh one should
    /// be fetched proactively.
    pub fn needs_refresh(&self) -> CredentialResult<bool> {
        Ok(self.remaining_lifetime()? < REFRESH_MARGIN)
    }
}

/// The RFC 7519 §4.1.4 expiration claim. Issuers write it as an integer or a decimal.
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()
    }
}

/// An unsigned JWT with `claims`, for tests of token handling.
#[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();

        // A one-second window absorbs the truncation to whole seconds in `exp`.
        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"));
    }
}