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/// 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                // Symmetry with the empty-string reject in
83                // 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/// RFC 8693 §4.1 actor claim: identifies the party that is acting on behalf
137/// of the subject. In a delegation chain, `sub` stays the human principal
138/// and `act` holds the agent identity. Nested `act` claims represent
139/// multi-hop delegation (agent A delegates to agent B: A is in `act`, B is
140/// in `act.act`).
141#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
142pub struct ActorClaim {
143    /// The acting party's identifier (e.g. `agent:billing-support`).
144    pub sub: String,
145    /// Nested actor for multi-hop delegation. Bounded by
146    /// `IdentityValidator::max_chain_depth` at validation time.
147    #[serde(default, skip_serializing_if = "Option::is_none")]
148    pub act: Option<Box<ActorClaim>>,
149}
150
151impl ActorClaim {
152    /// Count the nesting depth (0 = leaf, 1 = one nested act, etc.).
153    pub fn depth(&self) -> usize {
154        match &self.act {
155            None => 0,
156            Some(inner) => 1 + inner.depth(),
157        }
158    }
159}
160
161/// Claims carried by an AgentVisor AI NHI token.
162///
163/// Standard claims (`sub`, `iss`, `aud`, `iat`, `nbf`, `exp`, `jti`) plus the
164/// agent identity block and scopes. `parent_token` embeds the parent's full
165/// JWT for delegation-chain verification.
166///
167/// RFC 8693 additions: `azp` (authorized party — the client that obtained the
168/// token) and `act` (actor — the agent acting on behalf of `sub`).
169#[derive(Debug, Clone, Serialize, Deserialize)]
170pub struct NhiClaims {
171    /// Subject: the human principal (e.g. `user:alice@corp.com`). Under
172    /// RFC 8693 semantics, `sub` stays the human and agent identity
173    /// moves to `act.sub`.
174    pub sub: String,
175    /// Issuer (corporate IdP or the harness's own token service).
176    pub iss: String,
177    /// Audience (the harness deployment id). RFC 7519 §4.1.3 allows
178    /// either a single string or an array of strings; both are
179    /// accepted here so mainstream IdPs (Okta, Auth0, Azure AD,
180    /// Cognito) that emit multi-audience tokens are compatible.
181    pub aud: Audience,
182    /// Issued-at, epoch seconds.
183    pub iat: u64,
184    /// Not-before, epoch seconds.
185    #[serde(default, skip_serializing_if = "Option::is_none")]
186    pub nbf: Option<u64>,
187    /// Expiry, epoch seconds. `exp - iat` must be ≤ [`MAX_TTL_SECS`].
188    pub exp: u64,
189    /// Unique token id (revocation hook).
190    pub jti: String,
191    /// Authorized party (RFC 7519): the OAuth 2.0 client_id that obtained
192    /// this token. Used in token-exchange flows to track which client
193    /// requested the exchange.
194    #[serde(default, skip_serializing_if = "Option::is_none")]
195    pub azp: Option<String>,
196    /// RFC 8693 actor claim. When present, `sub` identifies the human
197    /// principal and `act.sub` identifies the agent acting on their behalf.
198    /// Nested `act.act` represents further delegation hops.
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub act: Option<ActorClaim>,
201    /// Agent instance uid bound into every emitted event.
202    pub instance_uid: String,
203    /// Agent charter.
204    pub charter: String,
205    /// Agent version.
206    pub version: String,
207    /// Granted scopes, e.g. `tool:db_write`, `payout`.
208    pub scopes: Vec<String>,
209    /// Parent agent's full JWT (delegation). `None` for root tokens.
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub parent_token: Option<String>,
212}
213
214impl NhiClaims {
215    /// True when `candidate`'s scopes are a subset of `self`'s.
216    pub fn scopes_cover(&self, candidate: &NhiClaims) -> bool {
217        candidate
218            .scopes
219            .iter()
220            .all(|s| self.scopes.iter().any(|p| p == s))
221    }
222}
223
224#[cfg(test)]
225mod tests {
226    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
227
228    use super::*;
229
230    fn claims(scopes: &[&str]) -> NhiClaims {
231        NhiClaims {
232            sub: "agent:a".into(),
233            iss: "idp".into(),
234            aud: "harness".into(),
235            iat: 0,
236            nbf: None,
237            exp: 60,
238            jti: "j1".into(),
239            azp: None,
240            act: None,
241            instance_uid: "i1".into(),
242            charter: "c".into(),
243            version: "1".into(),
244            scopes: scopes.iter().map(|s| (*s).to_owned()).collect(),
245            parent_token: None,
246        }
247    }
248
249    #[test]
250    fn subset_logic() {
251        let parent = claims(&["tool:read", "tool:write", "payout"]);
252        assert!(parent.scopes_cover(&claims(&["tool:read"])));
253        assert!(parent.scopes_cover(&claims(&["tool:read", "payout"])));
254        assert!(parent.scopes_cover(&claims(&[])));
255        assert!(!parent.scopes_cover(&claims(&["tool:admin"])));
256        assert!(!parent.scopes_cover(&claims(&["tool:read", "tool:admin"])));
257    }
258
259    /// Single-string aud (`"aud": "agentvisor"`) round-trips.
260    #[test]
261    fn audience_single_string_round_trips() {
262        let json = r#"{"aud":"agentvisor"}"#;
263        #[derive(Deserialize)]
264        struct Just {
265            aud: Audience,
266        }
267        let value: Just = serde_json::from_str(json).unwrap();
268        assert!(value.aud.contains("agentvisor"));
269        assert!(!value.aud.contains("other"));
270        assert_eq!(value.aud.primary(), "agentvisor");
271    }
272
273    /// Array aud (`"aud": ["agentvisor", "other"]`) is accepted per
274    /// RFC 7519 §4.1.3. This is exactly the case that used to lock out
275    /// Okta / Auth0 / Azure AD multi-audience apps.
276    #[test]
277    fn audience_array_form_is_accepted_and_probed() {
278        let json = r#"{"aud":["other","agentvisor","yet-another"]}"#;
279        #[derive(Deserialize)]
280        struct Just {
281            aud: Audience,
282        }
283        let value: Just = serde_json::from_str(json).unwrap();
284        assert!(value.aud.contains("agentvisor"));
285        assert!(value.aud.contains("other"));
286        assert!(!value.aud.contains("nope"));
287    }
288
289    /// Symmetry with the empty-string reject in
290    /// visit_str. An `aud: [""]` would
291    /// otherwise deserialize as `Multi(vec!["".to_owned()])` and
292    /// pass the "not empty array" gate while still carrying zero
293    /// meaningful audience entries.
294    #[test]
295    fn audience_array_with_empty_string_element_is_rejected() {
296        let json = r#"{"aud":["real","",""]}"#;
297        #[derive(Debug, Deserialize)]
298        #[allow(dead_code)]
299        struct Just {
300            aud: Audience,
301        }
302        let err = serde_json::from_str::<Just>(json).unwrap_err().to_string();
303        assert!(
304            err.contains("empty strings"),
305            "expected empty-string-in-array rejection, got: {err}",
306        );
307    }
308
309    /// Mirror of the empty-array rejection for the string half. The docstring
310    /// promises "non-empty string or non-empty array". Refuse the
311    /// empty string at deserialize time as defense-in-depth against a
312    /// misconfigured validator whose expected audience is also `""`.
313    #[test]
314    fn audience_empty_string_is_rejected_by_the_concrete_deserialize() {
315        let json = r#"{"aud":""}"#;
316        #[derive(Debug, Deserialize)]
317        #[allow(dead_code)]
318        struct Just {
319            aud: Audience,
320        }
321        let err = serde_json::from_str::<Just>(json).unwrap_err().to_string();
322        assert!(
323            err.contains("empty string"),
324            "expected empty-string rejection, got: {err}",
325        );
326    }
327
328    /// An empty audience array must be rejected at the
329    /// concrete deserialize step, so the audience gate remains sound
330    /// even if a future refactor drops `jsonwebtoken`'s
331    /// `set_audience` intersection check.
332    #[test]
333    fn audience_empty_array_is_rejected_by_the_concrete_deserialize() {
334        let json = r#"{"aud":[]}"#;
335        #[derive(Debug, Deserialize)]
336        #[allow(dead_code)]
337        struct Just {
338            aud: Audience,
339        }
340        let err = serde_json::from_str::<Just>(json).unwrap_err().to_string();
341        assert!(
342            err.contains("empty array"),
343            "expected empty-array rejection, got: {err}",
344        );
345    }
346
347    /// `aud` present but of an unsupported JSON type (number,
348    /// bool, null) must be rejected with a clear "expected a JWT
349    /// audience" message — the untagged enum variant used to fall
350    /// through with a much more confusing message.
351    #[test]
352    fn audience_unsupported_json_type_is_rejected() {
353        #[derive(Debug, Deserialize)]
354        #[allow(dead_code)]
355        struct Just {
356            aud: Audience,
357        }
358        for wrong in [r#"{"aud":42}"#, r#"{"aud":true}"#, r#"{"aud":null}"#] {
359            let err = serde_json::from_str::<Just>(wrong).unwrap_err().to_string();
360            assert!(
361                err.contains("JWT audience"),
362                "expected JWT-audience rejection for {wrong}, got: {err}",
363            );
364        }
365    }
366}