huskarl-core 0.6.4

Base library for huskarl (OAuth2 client) ecosystem.
Documentation
use std::borrow::Cow;

use serde::{Deserialize, Deserializer, Serialize};

use crate::jwk::PublicJwk;

/// The `cnf` (confirmation) claim, used to bind a JWT to a key (RFC 7800).
///
/// Only `jkt` (`DPoP` key thumbprint, RFC 9449) and `x5t#S256` (mTLS certificate
/// thumbprint, RFC 8705 §4) are the well-known members. `jwe` and `jku` are
/// captured to allow callers to detect and reject them.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfirmationClaim {
    /// The JWK thumbprint of the `DPoP` key bound to this token (RFC 9449 §4.2).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub jkt: Option<String>,
    /// The SHA-256 thumbprint of the client certificate bound to this token (RFC 8705 §4).
    #[serde(rename = "x5t#S256", skip_serializing_if = "Option::is_none")]
    pub x5t_s256: Option<String>,
    /// Encrypted key confirmation (RFC 7800 §3.3).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub jwe: Option<serde_json::Value>,
    /// JWK Set URL confirmation (RFC 7800 §3.5).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub jku: Option<serde_json::Value>,
}

fn deserialize_whole_or_fractional<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
where
    D: Deserializer<'de>,
{
    use serde::de;

    use crate::serde_utils::time::SecsVisitor;

    struct WholeOrFractionalOrNull;

    impl<'de> de::Visitor<'de> for WholeOrFractionalOrNull {
        type Value = Option<u64>;

        fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
            formatter.write_str("a non-negative numeric value, or null")
        }

        fn visit_u64<E: de::Error>(self, v: u64) -> Result<Self::Value, E> {
            SecsVisitor.visit_u64(v).map(Some)
        }

        fn visit_i64<E: de::Error>(self, v: i64) -> Result<Self::Value, E> {
            SecsVisitor.visit_i64(v).map(Some)
        }

        fn visit_f64<E: de::Error>(self, v: f64) -> Result<Self::Value, E> {
            SecsVisitor.visit_f64(v).map(Some)
        }

        fn visit_some<D: Deserializer<'de>>(
            self,
            deserializer: D,
        ) -> Result<Self::Value, D::Error> {
            deserializer.deserialize_any(self)
        }

        fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
            Ok(None)
        }

        fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
            Ok(None)
        }
    }

    deserializer.deserialize_any(WholeOrFractionalOrNull)
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(bound(deserialize = "ExtraHeaders: serde::de::Deserialize<'de>"))]
pub struct JwtHeader<'a, ExtraHeaders: Clone> {
    pub alg: Cow<'a, str>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub typ: Option<Cow<'a, str>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub kid: Option<Cow<'a, str>>,
    /// RFC 7515 §4.1.11: critical header parameters. Any non-empty value means the
    /// verifier MUST understand all listed parameters or reject the JWS entirely.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub crit: Vec<String>,
    /// Embedded public key — present only in `DPoP` proofs (RFC 9449 §4.2).
    /// Must not appear in ordinary JWTs; validators should reject tokens that include
    /// it unless they are specifically processing a `DPoP` proof.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub jwk: Option<PublicJwk>,
    #[serde(flatten, skip_serializing_if = "Option::is_none")]
    pub extra_headers: Option<Cow<'a, ExtraHeaders>>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(bound(deserialize = "Claims: serde::de::Deserialize<'de>"))]
pub struct JwtClaims<'a, Claims: Clone> {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub iss: Option<Cow<'a, str>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub sub: Option<Cow<'a, str>>,
    #[serde(
        default,
        skip_serializing_if = "Vec::is_empty",
        with = "crate::serde_utils::string::string_or_vec"
    )]
    pub aud: Vec<String>,
    #[serde(
        default,
        skip_serializing_if = "Option::is_none",
        deserialize_with = "deserialize_whole_or_fractional"
    )]
    pub iat: Option<u64>,
    #[serde(
        default,
        skip_serializing_if = "Option::is_none",
        deserialize_with = "deserialize_whole_or_fractional"
    )]
    pub exp: Option<u64>,
    #[serde(
        default,
        skip_serializing_if = "Option::is_none",
        deserialize_with = "deserialize_whole_or_fractional"
    )]
    pub nbf: Option<u64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub jti: Option<Cow<'a, str>>,
    /// Key confirmation claim (RFC 7800). Binds the token to a `DPoP` key or mTLS certificate.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub cnf: Option<ConfirmationClaim>,
    /// Additional claims beyond the registered JWT claim set.
    #[serde(flatten)]
    pub claims: Cow<'a, Claims>,
}

#[cfg(test)]
mod tests {
    use serde_json::json;

    use super::*;

    // --- aud serialization ---

    #[test]
    fn aud_serialize_empty() {
        let claims = JwtClaims::<'static, ()> {
            iss: None,
            sub: None,
            aud: vec![],
            iat: None,
            exp: None,
            nbf: None,
            jti: None,
            cnf: None,
            claims: Cow::Owned(()),
        };
        let v = serde_json::to_value(&claims).unwrap();
        assert!(v.get("aud").is_none());
    }

    #[test]
    fn aud_serialize_single() {
        let claims = JwtClaims::<'static, ()> {
            iss: None,
            sub: None,
            aud: vec!["https://example.com".into()],
            iat: None,
            exp: None,
            nbf: None,
            jti: None,
            cnf: None,
            claims: Cow::Owned(()),
        };
        let v = serde_json::to_value(&claims).unwrap();
        assert_eq!(v["aud"], json!("https://example.com"));
    }

    #[test]
    fn aud_serialize_multiple() {
        let claims = JwtClaims::<'static, ()> {
            iss: None,
            sub: None,
            aud: vec!["a".into(), "b".into()],
            iat: None,
            exp: None,
            nbf: None,
            jti: None,
            cnf: None,
            claims: Cow::Owned(()),
        };
        let v = serde_json::to_value(&claims).unwrap();
        assert_eq!(v["aud"], json!(["a", "b"]));
    }

    // --- aud deserialization ---

    #[test]
    fn aud_deserialize_string() {
        let j = r#"{"aud":"x"}"#;
        let c: JwtClaims<'_, ()> = serde_json::from_str(j).unwrap();
        assert_eq!(c.aud, vec!["x"]);
    }

    #[test]
    fn aud_deserialize_array() {
        let j = r#"{"aud":["a","b"]}"#;
        let c: JwtClaims<'_, ()> = serde_json::from_str(j).unwrap();
        assert_eq!(c.aud, vec!["a", "b"]);
    }

    #[test]
    fn aud_deserialize_null() {
        let j = r#"{"aud":null}"#;
        let c: JwtClaims<'_, ()> = serde_json::from_str(j).unwrap();
        assert!(c.aud.is_empty());
    }

    #[test]
    fn aud_deserialize_absent() {
        let j = r"{}";
        let c: JwtClaims<'_, ()> = serde_json::from_str(j).unwrap();
        assert!(c.aud.is_empty());
    }

    // --- Timestamp deserialization ---

    #[test]
    fn timestamp_integer() {
        let j = r#"{"iat":1234}"#;
        let c: JwtClaims<'_, ()> = serde_json::from_str(j).unwrap();
        assert_eq!(c.iat, Some(1234));
    }

    #[test]
    fn timestamp_float_truncates() {
        let j = r#"{"iat":1234.5}"#;
        let c: JwtClaims<'_, ()> = serde_json::from_str(j).unwrap();
        assert_eq!(c.iat, Some(1234));
    }

    #[test]
    fn timestamp_negative_errors() {
        let j = r#"{"iat":-1}"#;
        let result = serde_json::from_str::<JwtClaims<'_, ()>>(j);
        assert!(result.is_err());
    }

    #[test]
    fn timestamp_null() {
        let j = r#"{"iat":null}"#;
        let c: JwtClaims<'_, ()> = serde_json::from_str(j).unwrap();
        assert_eq!(c.iat, None);
    }

    #[test]
    fn timestamp_absent() {
        let j = r"{}";
        let c: JwtClaims<'_, ()> = serde_json::from_str(j).unwrap();
        assert_eq!(c.iat, None);
        assert_eq!(c.exp, None);
        assert_eq!(c.nbf, None);
    }

    #[test]
    fn all_timestamps() {
        let j = r#"{"iat":100,"exp":200,"nbf":50}"#;
        let c: JwtClaims<'_, ()> = serde_json::from_str(j).unwrap();
        assert_eq!(c.iat, Some(100));
        assert_eq!(c.exp, Some(200));
        assert_eq!(c.nbf, Some(50));
    }

    // --- ConfirmationClaim roundtrip ---

    #[test]
    fn confirmation_claim_roundtrip() {
        let cnf = ConfirmationClaim {
            jkt: Some("thumbprint-value".into()),
            x5t_s256: Some("cert-thumbprint".into()),
            jwe: None,
            jku: None,
        };
        let json = serde_json::to_string(&cnf).unwrap();
        assert!(json.contains("\"jkt\""));
        assert!(json.contains("\"x5t#S256\""));

        let roundtripped: ConfirmationClaim = serde_json::from_str(&json).unwrap();
        assert_eq!(roundtripped.jkt.as_deref(), Some("thumbprint-value"));
        assert_eq!(roundtripped.x5t_s256.as_deref(), Some("cert-thumbprint"));
    }

    #[test]
    fn confirmation_claim_skips_none() {
        let cnf = ConfirmationClaim {
            jkt: None,
            x5t_s256: None,
            jwe: None,
            jku: None,
        };
        let json = serde_json::to_string(&cnf).unwrap();
        assert_eq!(json, "{}");
    }
}