Skip to main content

identity_kit/jose/
claims.rs

1//! `JwtClaims`, the registered claims of RFC 7519 and every other claim of a JWT.
2
3use indexmap::IndexMap;
4use serde::de::Error as _;
5use serde::{Deserialize, Deserializer, Serialize};
6use serde_json::Value;
7
8use super::OneOrMany;
9
10/// A JWT claims set: the registered claims of RFC 7519 §4.1 as fields, every other claim
11/// (`vc`, `vp`, `nonce`, `cnf`, …) in `additional`.
12///
13/// Times are `NumericDate` values (RFC 7519 §2), whole seconds since the Unix epoch; a fraction
14/// is dropped on deserialization. With the `vc` feature, `JwtClaims::numeric_date`,
15/// `JwtClaims::timestamp` and the `with_exp`, `with_nbf` and `with_iat` setters convert from
16/// and to `chrono` times.
17///
18/// Decoding a token's payload with [`CompactJws::claims`](super::CompactJws::claims) checks
19/// nothing; validating the registered claims is the validators' job.
20///
21/// # Examples
22///
23/// ```
24/// use identity_kit::jose::JwtClaims;
25///
26/// let claims: JwtClaims = serde_json::from_str(
27///     r#"{"iss":"did:example:123","aud":["a","b"],"exp":1573029723,"nonce":"660!6345FSer"}"#,
28/// )?;
29/// assert_eq!(claims.iss.as_deref(), Some("did:example:123"));
30/// assert_eq!(claims.audiences().collect::<Vec<_>>(), ["a", "b"]);
31/// assert_eq!(claims.exp, Some(1573029723));
32/// assert_eq!(claims.additional["nonce"], "660!6345FSer");
33///
34/// let built = JwtClaims::new()
35///     .with_iss("did:example:123")
36///     .with_aud("did:example:verifier")
37///     .with_claim("nonce", "n-0S6_WzA2Mj");
38/// assert_eq!(
39///     serde_json::to_string(&built)?,
40///     r#"{"iss":"did:example:123","aud":"did:example:verifier","nonce":"n-0S6_WzA2Mj"}"#
41/// );
42/// # Ok::<(), serde_json::Error>(())
43/// ```
44#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
45#[non_exhaustive]
46pub struct JwtClaims {
47    /// `iss`: the issuer of the token.
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub iss: Option<String>,
50    /// `sub`: the subject of the token.
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub sub: Option<String>,
53    /// `aud`: the intended recipients.
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub aud: Option<OneOrMany<String>>,
56    /// `exp`: when the token expires.
57    #[serde(
58        default,
59        skip_serializing_if = "Option::is_none",
60        deserialize_with = "numeric_date"
61    )]
62    pub exp: Option<i64>,
63    /// `nbf`: when the token becomes valid.
64    #[serde(
65        default,
66        skip_serializing_if = "Option::is_none",
67        deserialize_with = "numeric_date"
68    )]
69    pub nbf: Option<i64>,
70    /// `iat`: when the token was issued.
71    #[serde(
72        default,
73        skip_serializing_if = "Option::is_none",
74        deserialize_with = "numeric_date"
75    )]
76    pub iat: Option<i64>,
77    /// `jti`: the identifier of the token.
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub jti: Option<String>,
80    /// Every other claim, in insertion order.
81    #[serde(flatten)]
82    pub additional: IndexMap<String, Value>,
83}
84
85/// Deserializes a `NumericDate` (RFC 7519 §2): a JSON number, possibly with a fraction, floored
86/// to whole seconds.
87#[expect(
88    clippy::cast_precision_loss,
89    clippy::cast_possible_truncation,
90    reason = "a fraction is floored to an `i64` only within the range `i64` covers"
91)]
92pub(crate) fn numeric_date<'de, D: Deserializer<'de>>(
93    deserializer: D,
94) -> Result<Option<i64>, D::Error> {
95    let Some(number) = Option::<serde_json::Number>::deserialize(deserializer)? else {
96        return Ok(None);
97    };
98    number
99        .as_i64()
100        .or_else(|| {
101            number
102                .as_f64()
103                .filter(|seconds| (i64::MIN as f64..i64::MAX as f64).contains(seconds))
104                .map(|seconds| seconds.floor() as i64)
105        })
106        .map(Some)
107        .ok_or_else(|| D::Error::custom(format!("NumericDate {number} is out of range")))
108}
109
110impl JwtClaims {
111    /// The registered claim names of RFC 7519 §4.1, which have fields of their own.
112    pub const REGISTERED: [&'static str; 7] = ["iss", "sub", "aud", "exp", "nbf", "iat", "jti"];
113
114    /// An empty claims set.
115    pub fn new() -> Self {
116        Self::default()
117    }
118
119    /// This claims set with `iss` set to `issuer`.
120    #[must_use]
121    pub fn with_iss(mut self, issuer: impl Into<String>) -> Self {
122        self.iss = Some(issuer.into());
123        self
124    }
125
126    /// This claims set with `sub` set to `subject`.
127    #[must_use]
128    pub fn with_sub(mut self, subject: impl Into<String>) -> Self {
129        self.sub = Some(subject.into());
130        self
131    }
132
133    /// This claims set with `jti` set to `id`.
134    #[must_use]
135    pub fn with_jti(mut self, id: impl Into<String>) -> Self {
136        self.jti = Some(id.into());
137        self
138    }
139
140    /// The audiences, in order; empty when `aud` is absent.
141    pub fn audiences(&self) -> impl Iterator<Item = &str> {
142        self.aud
143            .iter()
144            .flat_map(OneOrMany::iter)
145            .map(String::as_str)
146    }
147
148    /// This claims set with `aud` set to the one audience `audience`.
149    #[must_use]
150    pub fn with_aud(mut self, audience: impl Into<String>) -> Self {
151        self.aud = Some(OneOrMany::One(audience.into()));
152        self
153    }
154
155    /// This claims set with the claim `name` set to `value`.
156    ///
157    /// `name` should not be one of [`REGISTERED`](Self::REGISTERED), which have fields of their
158    /// own: the member would be serialized twice, and `vc::Jwt::sign` refuses such a claims set.
159    #[must_use]
160    pub fn with_claim(mut self, name: impl Into<String>, value: impl Into<Value>) -> Self {
161        self.additional.insert(name.into(), value.into());
162        self
163    }
164}