1use serde::{Deserialize, Serialize};
4
5pub const MAX_TTL_SECS: u64 = 15 * 60;
7
8#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum Audience {
25 Single(String),
27 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 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
142pub struct NhiClaims {
143 pub sub: String,
145 pub iss: String,
147 pub aud: Audience,
152 pub iat: u64,
154 #[serde(default, skip_serializing_if = "Option::is_none")]
156 pub nbf: Option<u64>,
157 pub exp: u64,
159 pub jti: String,
161 pub instance_uid: String,
163 pub charter: String,
165 pub version: String,
167 pub scopes: Vec<String>,
169 #[serde(default, skip_serializing_if = "Option::is_none")]
171 pub parent_token: Option<String>,
172}
173
174impl NhiClaims {
175 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 #[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 #[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 #[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 #[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 #[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 #[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}