rustauth-oauth 0.3.1

OAuth support for RustAuth.
Documentation
use serde_json::Value;
use time::OffsetDateTime;

use super::error::OAuthError;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TokenValidationOptions {
    pub audience: Vec<String>,
    pub issuer: Vec<String>,
    pub allowed_algorithms: Vec<String>,
    pub leeway_seconds: i64,
    pub require_expiration: bool,
    pub require_subject: bool,
    pub require_audience: bool,
    pub require_issuer: bool,
}

impl Default for TokenValidationOptions {
    fn default() -> Self {
        Self {
            audience: Vec::new(),
            issuer: Vec::new(),
            allowed_algorithms: default_allowed_algorithms(),
            leeway_seconds: 60,
            require_expiration: false,
            require_subject: false,
            require_audience: false,
            require_issuer: false,
        }
    }
}

impl TokenValidationOptions {
    pub fn new() -> Self {
        Self::default()
    }

    pub fn allow_hmac_algorithms(mut self) -> Self {
        for algorithm in ["HS256", "HS384", "HS512"] {
            if !self
                .allowed_algorithms
                .iter()
                .any(|value| value == algorithm)
            {
                self.allowed_algorithms.push(algorithm.to_owned());
            }
        }
        self
    }

    pub fn allowed_algorithms(
        mut self,
        algorithms: impl IntoIterator<Item = impl Into<String>>,
    ) -> Self {
        self.allowed_algorithms = algorithms.into_iter().map(Into::into).collect();
        self
    }

    pub fn leeway_seconds(mut self, seconds: i64) -> Self {
        self.leeway_seconds = seconds.max(0);
        self
    }

    pub fn require_standard_claims(mut self) -> Self {
        self.require_expiration = true;
        self.require_subject = true;
        self.require_audience = true;
        self.require_issuer = true;
        self
    }
}

fn default_allowed_algorithms() -> Vec<String> {
    [
        "RS256", "RS384", "RS512", "ES256", "ES384", "ES512", "EdDSA",
    ]
    .into_iter()
    .map(str::to_owned)
    .collect()
}

pub(crate) fn validate_payload_claims(
    claims: &serde_json::Map<String, Value>,
    options: &TokenValidationOptions,
) -> Result<(), OAuthError> {
    validate_temporal_claims_with_leeway(claims, options.leeway_seconds)?;
    validate_required_claims(claims, options)?;
    if !options.audience.is_empty() && !audience_matches(claims.get("aud"), &options.audience) {
        return Err(OAuthError::TokenVerification(
            "audience mismatch".to_owned(),
        ));
    }
    if !options.issuer.is_empty() {
        let issuer = claims.get("iss").and_then(Value::as_str);
        if !issuer.is_some_and(|issuer| options.issuer.iter().any(|expected| expected == issuer)) {
            return Err(OAuthError::TokenVerification("issuer mismatch".to_owned()));
        }
    }
    Ok(())
}

pub(crate) fn validate_temporal_claims_with_leeway(
    claims: &serde_json::Map<String, Value>,
    leeway_seconds: i64,
) -> Result<(), OAuthError> {
    let now = OffsetDateTime::now_utc().unix_timestamp();
    let leeway_seconds = leeway_seconds.max(0);
    if let Some(expiration) = required_numeric_claim(claims, "exp", false)? {
        if expiration <= now - leeway_seconds {
            return Err(OAuthError::TokenVerification("token expired".to_owned()));
        }
    }
    if let Some(not_before) = required_numeric_claim(claims, "nbf", false)? {
        if not_before > now + leeway_seconds {
            return Err(OAuthError::TokenVerification("token not active".to_owned()));
        }
    }
    if let Some(issued_at) = required_numeric_claim(claims, "iat", false)? {
        if issued_at > now + leeway_seconds {
            return Err(OAuthError::TokenVerification(
                "token issued in the future".to_owned(),
            ));
        }
    }
    Ok(())
}

pub(crate) fn validate_required_claims(
    claims: &serde_json::Map<String, Value>,
    options: &TokenValidationOptions,
) -> Result<(), OAuthError> {
    if options.require_expiration {
        required_numeric_claim(claims, "exp", true)?;
    }
    if options.require_subject {
        required_string_claim(claims, "sub")?;
    }
    if options.require_audience {
        required_audience_claim(claims)?;
    }
    if options.require_issuer {
        required_string_claim(claims, "iss")?;
    }
    Ok(())
}

pub(crate) fn audience_matches(value: Option<&Value>, expected: &[String]) -> bool {
    match value {
        Some(Value::String(audience)) => expected.iter().any(|expected| expected == audience),
        Some(Value::Array(audiences)) => audiences
            .iter()
            .filter_map(Value::as_str)
            .any(|audience| expected.iter().any(|expected| expected == audience)),
        _ => false,
    }
}

fn required_string_claim(
    claims: &serde_json::Map<String, Value>,
    claim: &'static str,
) -> Result<(), OAuthError> {
    match claims.get(claim) {
        Some(Value::String(value)) if !value.is_empty() => Ok(()),
        Some(_) => Err(OAuthError::InvalidClaim {
            claim,
            reason: "must be a non-empty string".to_owned(),
        }),
        None => Err(OAuthError::InvalidClaim {
            claim,
            reason: "missing required claim".to_owned(),
        }),
    }
}

fn required_audience_claim(claims: &serde_json::Map<String, Value>) -> Result<(), OAuthError> {
    match claims.get("aud") {
        Some(Value::String(value)) if !value.is_empty() => Ok(()),
        Some(Value::Array(values))
            if values
                .iter()
                .any(|value| value.as_str().is_some_and(|value| !value.is_empty())) =>
        {
            Ok(())
        }
        Some(_) => Err(OAuthError::InvalidClaim {
            claim: "aud",
            reason: "must be a non-empty string or string array".to_owned(),
        }),
        None => Err(OAuthError::InvalidClaim {
            claim: "aud",
            reason: "missing required claim".to_owned(),
        }),
    }
}

const INVALID_NUMERIC_TIMESTAMP_REASON: &str = "must be an integer NumericDate timestamp";

pub fn parse_numeric_timestamp_claim(
    value: Option<&Value>,
    claim: &'static str,
    required: bool,
) -> Result<Option<i64>, OAuthError> {
    match value {
        Some(Value::Number(number)) => {
            if let Some(timestamp) = number
                .as_i64()
                .or_else(|| number.as_u64().and_then(|value| i64::try_from(value).ok()))
            {
                Ok(Some(timestamp))
            } else {
                Err(OAuthError::InvalidClaim {
                    claim,
                    reason: INVALID_NUMERIC_TIMESTAMP_REASON.to_owned(),
                })
            }
        }
        Some(_) => Err(OAuthError::InvalidClaim {
            claim,
            reason: "must be a numeric timestamp".to_owned(),
        }),
        None if required => Err(OAuthError::InvalidClaim {
            claim,
            reason: "missing required claim".to_owned(),
        }),
        None => Ok(None),
    }
}

fn required_numeric_claim(
    claims: &serde_json::Map<String, Value>,
    claim: &'static str,
    required: bool,
) -> Result<Option<i64>, OAuthError> {
    parse_numeric_timestamp_claim(claims.get(claim), claim, required)
}

#[cfg(test)]
#[allow(clippy::expect_used)]
mod tests {
    use super::*;
    use serde_json::json;
    use std::str::FromStr;

    #[test]
    fn validate_temporal_claims_rejects_fractional_exp_nbf_and_iat() {
        let now = time::OffsetDateTime::now_utc().unix_timestamp();
        for (claim_name, value) in [
            ("exp", json!(0.1)),
            ("nbf", json!(now as f64 + 0.5)),
            ("iat", json!(now as f64 + 0.5)),
        ] {
            let mut claims = serde_json::Map::new();
            claims.insert(claim_name.to_owned(), value);
            let error = validate_temporal_claims_with_leeway(&claims, 0)
                .expect_err("fractional temporal claim should be rejected");
            assert!(matches!(
                error,
                OAuthError::InvalidClaim { claim, .. } if *claim == *claim_name
            ));
        }
    }

    #[test]
    fn validate_temporal_claims_rejects_oversized_exp() {
        let mut claims = serde_json::Map::new();
        claims.insert(
            "exp".to_owned(),
            Value::Number(
                serde_json::Number::from_str("9223372036854775808")
                    .expect("oversized exp should parse as JSON number"),
            ),
        );
        let error = validate_temporal_claims_with_leeway(&claims, 0)
            .expect_err("oversized exp should be rejected");
        assert!(matches!(
            error,
            OAuthError::InvalidClaim { claim: "exp", .. }
        ));
    }

    #[test]
    fn validate_required_claims_rejects_unparseable_exp_when_required() {
        let mut claims = serde_json::Map::new();
        claims.insert("exp".to_owned(), json!(1.5));
        let error = validate_required_claims(
            &claims,
            &TokenValidationOptions {
                require_expiration: true,
                ..TokenValidationOptions::default()
            },
        )
        .expect_err("required exp must be an integer timestamp");
        assert!(matches!(
            error,
            OAuthError::InvalidClaim { claim: "exp", .. }
        ));
    }

    #[test]
    fn validate_temporal_claims_accepts_integer_timestamps() {
        let now = time::OffsetDateTime::now_utc().unix_timestamp();
        let claims = serde_json::Map::from_iter([
            ("exp".to_owned(), json!(now + 3600)),
            ("nbf".to_owned(), json!(now - 60)),
            ("iat".to_owned(), json!(now)),
        ]);
        validate_temporal_claims_with_leeway(&claims, 60).expect("integer timestamps should pass");
    }
}