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}