Skip to main content

av_identity/
claims.rs

1//! NHI JWT claims.
2
3use serde::{Deserialize, Serialize};
4
5/// Hard TTL ceiling for NHI tokens: 15 minutes (brief Module D).
6pub const MAX_TTL_SECS: u64 = 15 * 60;
7
8/// The `aud` claim per RFC 7519 §4.1.3: "a StringOrURI value or an
9/// array of StringOrURI". Mainstream IdPs (Okta, Auth0, Azure AD,
10/// Cognito) emit the array form for multi-audience apps
11/// (`"aud": ["agentvisor", "some-other-svc"]`). Accepting only the
12/// string form silently locks the operator out at go-live with an
13/// `invalid type: sequence, expected string` error deep inside
14/// `jsonwebtoken::decode`, before our validator's aud check runs.
15///
16/// Round-12 F10 defense-in-depth: reject `"aud": []` at deserialize
17/// time. Without this guard `Multi(vec![])` would still be caught by
18/// `jsonwebtoken`'s `set_audience` intersection check, but a future
19/// refactor that removed the library gate would leave the empty-list
20/// shape silently accepting *any* token. Refusing at the concrete
21/// deserialize step means the audience gate remains sound at both
22/// layers.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum Audience {
25    /// Single-string form (`"aud": "agentvisor"`).
26    Single(String),
27    /// Array form (`"aud": ["agentvisor", "other"]`).
28    Multi(Vec<String>),
29}
30
31impl serde::Serialize for Audience {
32    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
33        match self {
34            Self::Single(value) => serializer.serialize_str(value),
35            Self::Multi(values) => {
36                use serde::ser::SerializeSeq as _;
37                let mut seq = serializer.serialize_seq(Some(values.len()))?;
38                for value in values {
39                    seq.serialize_element(value)?;
40                }
41                seq.end()
42            }
43        }
44    }
45}
46
47impl<'de> serde::Deserialize<'de> for Audience {
48    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
49        use serde::de::{SeqAccess, Visitor};
50        struct AudienceVisitor;
51        impl<'de> Visitor<'de> for AudienceVisitor {
52            type Value = Audience;
53            fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54                f.write_str("a JWT audience: a non-empty string or a non-empty array of strings")
55            }
56            fn visit_str<E: serde::de::Error>(self, value: &str) -> Result<Audience, E> {
57                if value.is_empty() {
58                    return Err(E::custom(
59                        "aud claim must not be an empty string; provide the audience or omit the claim",
60                    ));
61                }
62                Ok(Audience::Single(value.to_owned()))
63            }
64            fn visit_string<E: serde::de::Error>(self, value: String) -> Result<Audience, E> {
65                if value.is_empty() {
66                    return Err(E::custom(
67                        "aud claim must not be an empty string; provide the audience or omit the claim",
68                    ));
69                }
70                Ok(Audience::Single(value))
71            }
72            fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<Audience, A::Error> {
73                let mut values = Vec::with_capacity(seq.size_hint().unwrap_or(0));
74                while let Some(value) = seq.next_element::<String>()? {
75                    values.push(value);
76                }
77                if values.is_empty() {
78                    return Err(serde::de::Error::custom(
79                        "aud claim must not be an empty array; provide the audience or omit the claim",
80                    ));
81                }
82                // Round-14 F3: incomplete symmetry with round-13 F3
83                // (empty-string reject in visit_str). Without this, an
84                // `aud: [""]` would deserialize as
85                // `Multi(vec!["".to_owned()])` and pass the "not
86                // empty array" gate while still carrying zero
87                // meaningful audience entries. Any code path that
88                // treats `contains("expected")` returning false as
89                // "audience is present but doesn't match" (vs
90                // "audience is unspecified") is misled.
91                if values.iter().any(|v| v.is_empty()) {
92                    return Err(serde::de::Error::custom(
93                        "aud claim array must not contain empty strings",
94                    ));
95                }
96                Ok(Audience::Multi(values))
97            }
98        }
99        deserializer.deserialize_any(AudienceVisitor)
100    }
101}
102
103impl Audience {
104    /// True when `expected` appears in this audience claim (either the
105    /// single string equals it, or the array contains it).
106    pub fn contains(&self, expected: &str) -> bool {
107        match self {
108            Self::Single(value) => value == expected,
109            Self::Multi(values) => values.iter().any(|v| v == expected),
110        }
111    }
112
113    /// A borrowed view suitable for logging/display. Returns the first
114    /// entry of a multi-audience — the "primary" audience by
115    /// convention.
116    pub fn primary(&self) -> &str {
117        match self {
118            Self::Single(value) => value.as_str(),
119            Self::Multi(values) => values.first().map(String::as_str).unwrap_or(""),
120        }
121    }
122}
123
124impl From<&str> for Audience {
125    fn from(s: &str) -> Self {
126        Self::Single(s.to_owned())
127    }
128}
129
130impl From<String> for Audience {
131    fn from(s: String) -> Self {
132        Self::Single(s)
133    }
134}
135
136/// Claims carried by an AgentVisor AI NHI token.
137///
138/// Standard claims (`sub`, `iss`, `aud`, `iat`, `nbf`, `exp`, `jti`) plus the
139/// agent identity block and scopes. `parent_token` embeds the parent's full
140/// JWT for delegation-chain verification.
141#[derive(Debug, Clone, Serialize, Deserialize)]
142pub struct NhiClaims {
143    /// Subject: the agent principal (e.g. `agent:billing-support`).
144    pub sub: String,
145    /// Issuer (corporate IdP or the harness's own token service).
146    pub iss: String,
147    /// Audience (the harness deployment id). RFC 7519 §4.1.3 allows
148    /// either a single string or an array of strings; both are
149    /// accepted here so mainstream IdPs (Okta, Auth0, Azure AD,
150    /// Cognito) that emit multi-audience tokens are compatible.
151    pub aud: Audience,
152    /// Issued-at, epoch seconds.
153    pub iat: u64,
154    /// Not-before, epoch seconds.
155    #[serde(default, skip_serializing_if = "Option::is_none")]
156    pub nbf: Option<u64>,
157    /// Expiry, epoch seconds. `exp - iat` must be ≤ [`MAX_TTL_SECS`].
158    pub exp: u64,
159    /// Unique token id (revocation hook).
160    pub jti: String,
161    /// Agent instance uid bound into every emitted event.
162    pub instance_uid: String,
163    /// Agent charter.
164    pub charter: String,
165    /// Agent version.
166    pub version: String,
167    /// Granted scopes, e.g. `tool:db_write`, `payout`.
168    pub scopes: Vec<String>,
169    /// Parent agent's full JWT (delegation). `None` for root tokens.
170    #[serde(default, skip_serializing_if = "Option::is_none")]
171    pub parent_token: Option<String>,
172}
173
174impl NhiClaims {
175    /// True when `candidate`'s scopes are a subset of `self`'s.
176    pub fn scopes_cover(&self, candidate: &NhiClaims) -> bool {
177        candidate
178            .scopes
179            .iter()
180            .all(|s| self.scopes.iter().any(|p| p == s))
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
187
188    use super::*;
189
190    fn claims(scopes: &[&str]) -> NhiClaims {
191        NhiClaims {
192            sub: "agent:a".into(),
193            iss: "idp".into(),
194            aud: "harness".into(),
195            iat: 0,
196            nbf: None,
197            exp: 60,
198            jti: "j1".into(),
199            instance_uid: "i1".into(),
200            charter: "c".into(),
201            version: "1".into(),
202            scopes: scopes.iter().map(|s| (*s).to_owned()).collect(),
203            parent_token: None,
204        }
205    }
206
207    #[test]
208    fn subset_logic() {
209        let parent = claims(&["tool:read", "tool:write", "payout"]);
210        assert!(parent.scopes_cover(&claims(&["tool:read"])));
211        assert!(parent.scopes_cover(&claims(&["tool:read", "payout"])));
212        assert!(parent.scopes_cover(&claims(&[])));
213        assert!(!parent.scopes_cover(&claims(&["tool:admin"])));
214        assert!(!parent.scopes_cover(&claims(&["tool:read", "tool:admin"])));
215    }
216
217    /// Single-string aud (`"aud": "agentvisor"`) round-trips.
218    #[test]
219    fn audience_single_string_round_trips() {
220        let json = r#"{"aud":"agentvisor"}"#;
221        #[derive(Deserialize)]
222        struct Just {
223            aud: Audience,
224        }
225        let value: Just = serde_json::from_str(json).unwrap();
226        assert!(value.aud.contains("agentvisor"));
227        assert!(!value.aud.contains("other"));
228        assert_eq!(value.aud.primary(), "agentvisor");
229    }
230
231    /// Array aud (`"aud": ["agentvisor", "other"]`) is accepted per
232    /// RFC 7519 §4.1.3. This is exactly the case that used to lock out
233    /// Okta / Auth0 / Azure AD multi-audience apps.
234    #[test]
235    fn audience_array_form_is_accepted_and_probed() {
236        let json = r#"{"aud":["other","agentvisor","yet-another"]}"#;
237        #[derive(Deserialize)]
238        struct Just {
239            aud: Audience,
240        }
241        let value: Just = serde_json::from_str(json).unwrap();
242        assert!(value.aud.contains("agentvisor"));
243        assert!(value.aud.contains("other"));
244        assert!(!value.aud.contains("nope"));
245    }
246
247    /// Round-14 F3: incomplete symmetry with round-13 F3
248    /// (empty-string reject in visit_str). An `aud: [""]` would
249    /// otherwise deserialize as `Multi(vec!["".to_owned()])` and
250    /// pass the "not empty array" gate while still carrying zero
251    /// meaningful audience entries.
252    #[test]
253    fn audience_array_with_empty_string_element_is_rejected() {
254        let json = r#"{"aud":["real","",""]}"#;
255        #[derive(Debug, Deserialize)]
256        #[allow(dead_code)]
257        struct Just {
258            aud: Audience,
259        }
260        let err = serde_json::from_str::<Just>(json).unwrap_err().to_string();
261        assert!(
262            err.contains("empty strings"),
263            "expected empty-string-in-array rejection, got: {err}",
264        );
265    }
266
267    /// Round-13 F3: mirror of F10 for the string half. The docstring
268    /// promises "non-empty string or non-empty array". Refuse the
269    /// empty string at deserialize time as defense-in-depth against a
270    /// misconfigured validator whose expected audience is also `""`.
271    #[test]
272    fn audience_empty_string_is_rejected_by_the_concrete_deserialize() {
273        let json = r#"{"aud":""}"#;
274        #[derive(Debug, Deserialize)]
275        #[allow(dead_code)]
276        struct Just {
277            aud: Audience,
278        }
279        let err = serde_json::from_str::<Just>(json).unwrap_err().to_string();
280        assert!(
281            err.contains("empty string"),
282            "expected empty-string rejection, got: {err}",
283        );
284    }
285
286    /// Round-12 F10: an empty audience array must be rejected at the
287    /// concrete deserialize step, so the audience gate remains sound
288    /// even if a future refactor drops `jsonwebtoken`'s
289    /// `set_audience` intersection check.
290    #[test]
291    fn audience_empty_array_is_rejected_by_the_concrete_deserialize() {
292        let json = r#"{"aud":[]}"#;
293        #[derive(Debug, Deserialize)]
294        #[allow(dead_code)]
295        struct Just {
296            aud: Audience,
297        }
298        let err = serde_json::from_str::<Just>(json).unwrap_err().to_string();
299        assert!(
300            err.contains("empty array"),
301            "expected empty-array rejection, got: {err}",
302        );
303    }
304
305    /// Round-12: `aud` present but of an unsupported JSON type (number,
306    /// bool, null) must be rejected with a clear "expected a JWT
307    /// audience" message — the untagged enum variant used to fall
308    /// through with a much more confusing message.
309    #[test]
310    fn audience_unsupported_json_type_is_rejected() {
311        #[derive(Debug, Deserialize)]
312        #[allow(dead_code)]
313        struct Just {
314            aud: Audience,
315        }
316        for wrong in [r#"{"aud":42}"#, r#"{"aud":true}"#, r#"{"aud":null}"#] {
317            let err = serde_json::from_str::<Just>(wrong).unwrap_err().to_string();
318            assert!(
319                err.contains("JWT audience"),
320                "expected JWT-audience rejection for {wrong}, got: {err}",
321            );
322        }
323    }
324}