moq_token/
claims.rs

1use serde::{Deserialize, Deserializer, Serialize};
2use serde_with::{serde_as, TimestampSeconds};
3
4fn is_false(value: &bool) -> bool {
5	!value
6}
7
8fn string_or_vec<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
9where
10	D: Deserializer<'de>,
11{
12	#[derive(Deserialize)]
13	#[serde(untagged)]
14	enum StringOrVec {
15		String(String),
16		Vec(Vec<String>),
17	}
18
19	match StringOrVec::deserialize(deserializer)? {
20		StringOrVec::String(s) => Ok(vec![s]),
21		StringOrVec::Vec(v) => Ok(v),
22	}
23}
24
25#[serde_as]
26#[derive(Debug, Serialize, Deserialize, Default, Clone)]
27#[serde_with::skip_serializing_none]
28#[serde(default)]
29pub struct Claims {
30	/// The root for the publish/subscribe options below.
31	/// It's mostly for compression and is optional, defaulting to the empty string.
32	#[serde(default, rename = "root", skip_serializing_if = "String::is_empty")]
33	pub root: String,
34
35	/// If specified, the user can publish any matching broadcasts.
36	/// If not specified, the user will not publish any broadcasts.
37	#[serde(
38		default,
39		rename = "put",
40		skip_serializing_if = "Vec::is_empty",
41		deserialize_with = "string_or_vec"
42	)]
43	pub publish: Vec<String>,
44
45	/// If true, then this client is considered a cluster node.
46	/// Both the client and server will only announce broadcasts from non-cluster clients.
47	/// This avoids convoluted routing, as only the primary origin will announce.
48	//
49	// TODO This shouldn't be part of the token.
50	#[serde(default, rename = "cluster", skip_serializing_if = "is_false")]
51	pub cluster: bool,
52
53	/// If specified, the user can subscribe to any matching broadcasts.
54	/// If not specified, the user will not receive announcements and cannot subscribe to any broadcasts.
55	// NOTE: This can't be renamed to "sub" because that's a reserved JWT field.
56	#[serde(
57		default,
58		rename = "get",
59		skip_serializing_if = "Vec::is_empty",
60		deserialize_with = "string_or_vec"
61	)]
62	pub subscribe: Vec<String>,
63
64	/// The expiration time of the token as a unix timestamp.
65	#[serde(rename = "exp")]
66	#[serde_as(as = "Option<TimestampSeconds<i64>>")]
67	pub expires: Option<std::time::SystemTime>,
68
69	/// The issued time of the token as a unix timestamp.
70	#[serde(rename = "iat")]
71	#[serde_as(as = "Option<TimestampSeconds<i64>>")]
72	pub issued: Option<std::time::SystemTime>,
73}
74
75impl Claims {
76	pub fn validate(&self) -> anyhow::Result<()> {
77		if self.publish.is_empty() && self.subscribe.is_empty() {
78			anyhow::bail!("no publish or subscribe allowed; token is useless");
79		}
80
81		Ok(())
82	}
83}
84
85#[cfg(test)]
86mod tests {
87	use super::*;
88
89	use std::time::{Duration, SystemTime};
90
91	fn create_test_claims() -> Claims {
92		Claims {
93			root: "test-path".to_string(),
94			publish: vec!["test-pub".into()],
95			cluster: false,
96			subscribe: vec!["test-sub".into()],
97			expires: Some(SystemTime::now() + Duration::from_secs(3600)),
98			issued: Some(SystemTime::now()),
99		}
100	}
101
102	#[test]
103	fn test_claims_validation_success() {
104		let claims = create_test_claims();
105		assert!(claims.validate().is_ok());
106	}
107
108	#[test]
109	fn test_claims_validation_no_publish_or_subscribe() {
110		let claims = Claims {
111			root: "test-path".to_string(),
112			publish: vec![],
113			subscribe: vec![],
114			cluster: false,
115			expires: None,
116			issued: None,
117		};
118
119		let result = claims.validate();
120		assert!(result.is_err());
121		assert!(result
122			.unwrap_err()
123			.to_string()
124			.contains("no publish or subscribe allowed; token is useless"));
125	}
126
127	#[test]
128	fn test_claims_validation_only_publish() {
129		let claims = Claims {
130			root: "test-path".to_string(),
131			publish: vec!["test-pub".into()],
132			subscribe: vec![],
133			cluster: false,
134			expires: None,
135			issued: None,
136		};
137
138		assert!(claims.validate().is_ok());
139	}
140
141	#[test]
142	fn test_claims_validation_only_subscribe() {
143		let claims = Claims {
144			root: "test-path".to_string(),
145			publish: vec![],
146			subscribe: vec!["test-sub".into()],
147			cluster: false,
148			expires: None,
149			issued: None,
150		};
151
152		assert!(claims.validate().is_ok());
153	}
154
155	#[test]
156	fn test_claims_validation_path_not_prefix_relative_publish() {
157		let claims = Claims {
158			root: "test-path".to_string(),        // no trailing slash
159			publish: vec!["relative-pub".into()], // relative path without leading slash
160			subscribe: vec![],
161			cluster: false,
162			expires: None,
163			issued: None,
164		};
165
166		let result = claims.validate();
167		assert!(result.is_ok()); // Now passes because slashes are implicitly added
168	}
169
170	#[test]
171	fn test_claims_validation_path_not_prefix_relative_subscribe() {
172		let claims = Claims {
173			root: "test-path".to_string(), // no trailing slash
174			publish: vec![],
175			subscribe: vec!["relative-sub".into()], // relative path without leading slash
176			cluster: false,
177			expires: None,
178			issued: None,
179		};
180
181		let result = claims.validate();
182		assert!(result.is_ok()); // Now passes because slashes are implicitly added
183	}
184
185	#[test]
186	fn test_claims_validation_path_not_prefix_absolute_publish() {
187		let claims = Claims {
188			root: "test-path".to_string(),         // no trailing slash
189			publish: vec!["/absolute-pub".into()], // absolute path with leading slash
190			subscribe: vec![],
191			cluster: false,
192			expires: None,
193			issued: None,
194		};
195
196		assert!(claims.validate().is_ok());
197	}
198
199	#[test]
200	fn test_claims_validation_path_not_prefix_absolute_subscribe() {
201		let claims = Claims {
202			root: "test-path".to_string(), // no trailing slash
203			publish: vec![],
204			subscribe: vec!["/absolute-sub".into()], // absolute path with leading slash
205			cluster: false,
206			expires: None,
207			issued: None,
208		};
209
210		assert!(claims.validate().is_ok());
211	}
212
213	#[test]
214	fn test_claims_validation_path_not_prefix_empty_publish() {
215		let claims = Claims {
216			root: "test-path".to_string(), // no trailing slash
217			publish: vec!["".into()],      // empty string
218			subscribe: vec![],
219			cluster: false,
220			expires: None,
221			issued: None,
222		};
223
224		assert!(claims.validate().is_ok());
225	}
226
227	#[test]
228	fn test_claims_validation_path_not_prefix_empty_subscribe() {
229		let claims = Claims {
230			root: "test-path".to_string(), // no trailing slash
231			publish: vec![],
232			subscribe: vec!["".into()], // empty string
233			cluster: false,
234			expires: None,
235			issued: None,
236		};
237
238		assert!(claims.validate().is_ok());
239	}
240
241	#[test]
242	fn test_claims_validation_path_is_prefix() {
243		let claims = Claims {
244			root: "test-path".to_string(),          // with trailing slash
245			publish: vec!["relative-pub".into()],   // relative path is ok when path is prefix
246			subscribe: vec!["relative-sub".into()], // relative path is ok when path is prefix
247			cluster: false,
248			expires: None,
249			issued: None,
250		};
251
252		assert!(claims.validate().is_ok());
253	}
254
255	#[test]
256	fn test_claims_validation_empty_path() {
257		let claims = Claims {
258			root: "".to_string(), // empty path
259			publish: vec!["test-pub".into()],
260			subscribe: vec![],
261			cluster: false,
262			expires: None,
263			issued: None,
264		};
265
266		assert!(claims.validate().is_ok());
267	}
268
269	#[test]
270	fn test_claims_serde() {
271		let claims = create_test_claims();
272		let json = serde_json::to_string(&claims).unwrap();
273		let deserialized: Claims = serde_json::from_str(&json).unwrap();
274
275		assert_eq!(deserialized.root, claims.root);
276		assert_eq!(deserialized.publish, claims.publish);
277		assert_eq!(deserialized.subscribe, claims.subscribe);
278		assert_eq!(deserialized.cluster, claims.cluster);
279	}
280
281	#[test]
282	fn test_claims_default() {
283		let claims = Claims::default();
284		assert_eq!(claims.root, "");
285		assert!(claims.publish.is_empty());
286		assert!(claims.subscribe.is_empty());
287		assert!(!claims.cluster);
288		assert_eq!(claims.expires, None);
289		assert_eq!(claims.issued, None);
290	}
291
292	#[test]
293	fn test_is_false_helper() {
294		assert!(is_false(&false));
295		assert!(!is_false(&true));
296	}
297
298	#[test]
299	fn test_deserialize_string_as_vec() {
300		let json = r#"{
301			"root": "test",
302			"put": "single-publish",
303			"get": "single-subscribe"
304		}"#;
305
306		let claims: Claims = serde_json::from_str(json).unwrap();
307		assert_eq!(claims.publish, vec!["single-publish"]);
308		assert_eq!(claims.subscribe, vec!["single-subscribe"]);
309	}
310
311	#[test]
312	fn test_deserialize_vec_as_vec() {
313		let json = r#"{
314			"root": "test",
315			"put": ["pub1", "pub2"],
316			"get": ["sub1", "sub2"]
317		}"#;
318
319		let claims: Claims = serde_json::from_str(json).unwrap();
320		assert_eq!(claims.publish, vec!["pub1", "pub2"]);
321		assert_eq!(claims.subscribe, vec!["sub1", "sub2"]);
322	}
323
324	#[test]
325	fn test_deserialize_mixed() {
326		let json = r#"{
327			"root": "test",
328			"put": "single",
329			"get": ["multi1", "multi2"]
330		}"#;
331
332		let claims: Claims = serde_json::from_str(json).unwrap();
333		assert_eq!(claims.publish, vec!["single"]);
334		assert_eq!(claims.subscribe, vec!["multi1", "multi2"]);
335	}
336}