Skip to main content

moq_token/
claims.rs

1use crate::path;
2use serde::{Deserialize, Serialize};
3use serde_with::{OneOrMany, TimestampSeconds, formats::PreferMany, serde_as};
4
5/// The immutable ceiling on what a key may grant, embedded in its JWK.
6///
7/// Paths in `publish` and `subscribe` are relative to `root`, matching token claim
8/// semantics. A key signs a token only when every path the token grants sits at or
9/// beneath one the scope allows, in the same role; see [`allows`](Self::allows).
10///
11/// The scope is fixed at key generation. Widening it means minting a new key, which
12/// is the point: a leaked scoped key can never be talked into signing more than it
13/// already could. A key with no scope at all is unrestricted, so keys minted before
14/// scopes existed keep working.
15#[derive(Debug, Serialize, Deserialize, Default, Clone, PartialEq, Eq)]
16pub struct Scope {
17	/// The root for the publish/subscribe prefixes below.
18	#[serde(default, skip_serializing_if = "String::is_empty")]
19	pub root: String,
20
21	/// Prefixes this key may grant to publishers.
22	#[serde(default, rename = "put", skip_serializing_if = "Vec::is_empty")]
23	pub publish: Vec<String>,
24
25	/// Prefixes this key may grant to subscribers.
26	#[serde(default, rename = "get", skip_serializing_if = "Vec::is_empty")]
27	pub subscribe: Vec<String>,
28}
29
30impl Scope {
31	/// Returns an error when the scope permits nothing, making the key unusable.
32	pub fn validate(&self) -> crate::Result<()> {
33		if self.publish.is_empty() && self.subscribe.is_empty() {
34			return Err(crate::Error::UselessScope);
35		}
36
37		Ok(())
38	}
39
40	/// Whether every path `claims` grants is covered by this scope, per role.
41	///
42	/// Both sides are resolved against their own root before comparing, so the same
43	/// grant expressed as `root: "demo"` + `put: ["room"]` or as `put: ["demo/room"]`
44	/// is treated identically. Matching is segment-aware, so a scope of `live` does
45	/// not cover `lively`, and the roles are checked independently: a publish-only
46	/// scope never authorizes a subscribe grant.
47	///
48	/// An empty prefix covers everything beneath the scope root, so a scope of
49	/// `root: "demo"` + `put: [""]` grants publish anywhere under `demo`.
50	pub fn allows(&self, claims: &Claims) -> bool {
51		covers(&self.root, &self.publish, &claims.root, &claims.publish)
52			&& covers(&self.root, &self.subscribe, &claims.root, &claims.subscribe)
53	}
54}
55
56/// Whether every `requested` path (relative to `claims_root`) sits beneath some
57/// `granted` prefix (relative to `scope_root`).
58///
59/// An empty `granted` denies everything, which is what makes a publish-only scope
60/// reject subscribe grants rather than ignoring them.
61fn covers(scope_root: &str, granted: &[String], claims_root: &str, requested: &[String]) -> bool {
62	let absolute = |root: &str, relative: &str| path::join(&path::normalize(root), &path::normalize(relative));
63
64	requested.iter().all(|request| {
65		let request = absolute(claims_root, request);
66		granted
67			.iter()
68			.any(|grant| path::has_prefix(&request, &absolute(scope_root, grant)))
69	})
70}
71
72/// The access a [`Claims`] grants at a specific path, with every prefix rebased so
73/// it is relative to that path.
74///
75/// Produced by [`Claims::authorize`]. An empty string grants the path itself and
76/// everything beneath it.
77#[derive(Debug, Clone, Default, PartialEq, Eq)]
78pub struct Permissions {
79	/// Paths the holder may subscribe to, relative to the authorized path.
80	pub subscribe: Vec<String>,
81
82	/// Paths the holder may publish to, relative to the authorized path.
83	pub publish: Vec<String>,
84}
85
86/// The payload of a token: a root, plus the publish/subscribe prefixes granted beneath it.
87///
88/// Build one from [`Default`] with the `with_*` setters, sign it with
89/// [`Key::sign`](crate::Key::sign), and scope it to a connection with
90/// [`authorize`](Self::authorize).
91///
92/// ```no_run
93/// let claims = moq_token::Claims::default()
94///     .with_root("room/123")
95///     .with_publish(["alice"])
96///     .with_subscribe([""]);
97/// ```
98#[serde_with::skip_serializing_none]
99#[serde_as]
100#[derive(Debug, Serialize, Deserialize, Default, Clone)]
101#[serde(default)]
102#[non_exhaustive]
103pub struct Claims {
104	/// The root for the publish/subscribe options below.
105	/// It's mostly for compression and is optional, defaulting to the empty string.
106	#[serde(default, rename = "root", skip_serializing_if = "String::is_empty")]
107	pub root: String,
108
109	/// If specified, the user can publish any matching broadcasts.
110	/// If not specified, the user will not publish any broadcasts.
111	#[serde(default, rename = "put", skip_serializing_if = "Vec::is_empty")]
112	#[serde_as(as = "OneOrMany<_, PreferMany>")]
113	pub publish: Vec<String>,
114
115	/// If specified, the user can subscribe to any matching broadcasts.
116	/// If not specified, the user will not receive announcements and cannot subscribe to any broadcasts.
117	// NOTE: This can't be renamed to "sub" because that's a reserved JWT field.
118	#[serde(default, rename = "get", skip_serializing_if = "Vec::is_empty")]
119	#[serde_as(as = "OneOrMany<_, PreferMany>")]
120	pub subscribe: Vec<String>,
121
122	/// The expiration time of the token as a unix timestamp.
123	#[serde(rename = "exp")]
124	#[serde_as(as = "Option<TimestampSeconds<i64>>")]
125	pub expires: Option<std::time::SystemTime>,
126
127	/// The issued time of the token as a unix timestamp.
128	#[serde(rename = "iat")]
129	#[serde_as(as = "Option<TimestampSeconds<i64>>")]
130	pub issued: Option<std::time::SystemTime>,
131}
132
133impl Claims {
134	/// Set the root that the publish/subscribe prefixes are relative to.
135	pub fn with_root(mut self, root: impl Into<String>) -> Self {
136		self.root = root.into();
137		self
138	}
139
140	/// Grant publish access to these prefixes, relative to the root.
141	pub fn with_publish(mut self, paths: impl IntoIterator<Item = impl Into<String>>) -> Self {
142		self.publish = paths.into_iter().map(Into::into).collect();
143		self
144	}
145
146	/// Grant subscribe access to these prefixes, relative to the root.
147	pub fn with_subscribe(mut self, paths: impl IntoIterator<Item = impl Into<String>>) -> Self {
148		self.subscribe = paths.into_iter().map(Into::into).collect();
149		self
150	}
151
152	/// Expire the token at this time. Enforced by [`Key::verify`](crate::Key::verify).
153	///
154	/// Accepts an `Option` so a caller can pass one through without unwrapping it.
155	pub fn with_expires(mut self, at: impl Into<Option<std::time::SystemTime>>) -> Self {
156		self.expires = at.into();
157		self
158	}
159
160	/// Record when the token was issued. Purely informational; nothing enforces it.
161	///
162	/// Accepts an `Option` so a caller can pass one through without unwrapping it.
163	pub fn with_issued(mut self, at: impl Into<Option<std::time::SystemTime>>) -> Self {
164		self.issued = at.into();
165		self
166	}
167
168	/// Returns an error when the token grants nothing at all, making it useless.
169	pub fn validate(&self) -> crate::Result<()> {
170		if self.publish.is_empty() && self.subscribe.is_empty() {
171			return Err(crate::Error::UselessToken);
172		}
173
174		Ok(())
175	}
176
177	/// The access these claims grant at `path`, rebased so each returned prefix is
178	/// relative to `path`.
179	///
180	/// `path` and [`root`](Self::root) must overlap, in either direction:
181	///
182	/// - `path` extends the root (root `demo`, path `demo/room`), so the extra
183	///   `room` narrows each prefix and drops the ones outside it.
184	/// - `path` is a parent of the root (root `demo`, path ``), so `demo` is
185	///   prepended to each prefix to keep it anchored where the token points.
186	///
187	/// Matching is segment-aware, so a root of `foo` does not cover `foobar`.
188	/// Slashes at the boundaries are implicit: `/demo/` and `demo` are the same path.
189	///
190	/// Returns [`Error::RootMismatch`](crate::Error::RootMismatch) when the two don't
191	/// overlap, and [`Error::NoAccess`](crate::Error::NoAccess) when they do but every
192	/// prefix falls outside `path`.
193	///
194	/// This is authorization only. Verify the signature first with
195	/// [`Key::verify`](crate::Key::verify), which is where expiry is enforced.
196	pub fn authorize(&self, path: &str) -> crate::Result<Permissions> {
197		let path = path::normalize(path);
198		let root = path::normalize(&self.root);
199
200		// Exactly one of these is non-empty: `suffix` is how far the path reaches
201		// past the root, `prefix` is how far the root reaches past the path.
202		let (suffix, prefix) = if let Some(suffix) = path::strip_prefix(&path, &root) {
203			(suffix, "")
204		} else if let Some(prefix) = path::strip_prefix(&root, &path) {
205			("", prefix)
206		} else {
207			return Err(crate::Error::RootMismatch(path));
208		};
209
210		let scope = |paths: &[String]| -> Vec<String> {
211			paths
212				.iter()
213				.filter_map(|granted| {
214					let granted = path::join(prefix, &path::normalize(granted));
215
216					if let Some(remaining) = path::strip_prefix(&granted, suffix) {
217						// The grant covers the path; keep what's left below it.
218						Some(remaining.to_string())
219					} else if path::has_prefix(suffix, &granted) {
220						// The grant stops short of the path but still contains it,
221						// so everything below the path is granted.
222						Some(String::new())
223					} else {
224						None
225					}
226				})
227				.collect()
228		};
229
230		let permissions = Permissions {
231			subscribe: scope(&self.subscribe),
232			publish: scope(&self.publish),
233		};
234
235		if permissions.subscribe.is_empty() && permissions.publish.is_empty() {
236			return Err(crate::Error::NoAccess(path));
237		}
238
239		Ok(permissions)
240	}
241}
242
243#[cfg(test)]
244mod tests {
245	use super::*;
246
247	use std::time::{Duration, SystemTime};
248
249	fn create_test_claims() -> Claims {
250		Claims {
251			root: "test-path".to_string(),
252			publish: vec!["test-pub".into()],
253			subscribe: vec!["test-sub".into()],
254			expires: Some(SystemTime::now() + Duration::from_secs(3600)),
255			issued: Some(SystemTime::now()),
256		}
257	}
258
259	#[test]
260	fn scope_allows_contained_claims() {
261		let scope = Scope {
262			root: "project".into(),
263			publish: vec!["live".into()],
264			subscribe: vec!["watch".into()],
265		};
266		let claims = Claims {
267			root: "project/live/room".into(),
268			publish: vec!["".into()],
269			subscribe: vec![],
270			..Default::default()
271		};
272		assert!(scope.allows(&claims));
273	}
274
275	#[test]
276	fn scope_rejects_sibling_and_role_escalation() {
277		let scope = Scope {
278			root: "project".into(),
279			publish: vec!["live".into()],
280			subscribe: vec![],
281		};
282		let sibling = Claims {
283			root: "project/lively".into(),
284			publish: vec!["".into()],
285			..Default::default()
286		};
287		let role = Claims {
288			root: "project/live".into(),
289			subscribe: vec!["".into()],
290			..Default::default()
291		};
292		assert!(!scope.allows(&sibling));
293		assert!(!scope.allows(&role));
294	}
295
296	#[test]
297	fn scope_ignores_how_the_root_is_split() {
298		// The same grant, expressed three ways, must compare identically.
299		let scope = Scope {
300			root: "project".into(),
301			publish: vec!["live".into()],
302			subscribe: vec![],
303		};
304
305		for claims in [
306			Claims {
307				root: "project".into(),
308				publish: vec!["live/room".into()],
309				..Default::default()
310			},
311			Claims {
312				root: String::new(),
313				publish: vec!["project/live/room".into()],
314				..Default::default()
315			},
316			Claims {
317				root: "/project/live/".into(),
318				publish: vec!["/room".into()],
319				..Default::default()
320			},
321		] {
322			assert!(scope.allows(&claims), "{claims:?}");
323		}
324	}
325
326	#[test]
327	fn scope_rejects_escaping_above_its_root() {
328		let scope = Scope {
329			root: "project".into(),
330			publish: vec!["live".into()],
331			subscribe: vec![],
332		};
333
334		// A root above the scope's does not widen it, even though the empty prefix
335		// would grant everything within the scope.
336		let claims = Claims {
337			root: String::new(),
338			publish: vec!["".into()],
339			..Default::default()
340		};
341		assert!(!scope.allows(&claims));
342	}
343
344	#[test]
345	fn scope_empty_prefix_grants_everything_beneath_it() {
346		let scope = Scope {
347			root: "project".into(),
348			publish: vec![String::new()],
349			subscribe: vec![],
350		};
351		let claims = Claims {
352			root: "project/anything/deep".into(),
353			publish: vec!["".into()],
354			..Default::default()
355		};
356		assert!(scope.allows(&claims));
357	}
358
359	#[test]
360	fn scope_requires_every_requested_path() {
361		// One allowed path does not carry an unallowed sibling along with it.
362		let scope = Scope {
363			root: "project".into(),
364			publish: vec!["live".into()],
365			subscribe: vec![],
366		};
367		let claims = Claims {
368			root: "project".into(),
369			publish: vec!["live/room".into(), "other".into()],
370			..Default::default()
371		};
372		assert!(!scope.allows(&claims));
373	}
374
375	#[test]
376	fn scope_without_grants_is_useless() {
377		assert!(matches!(Scope::default().validate(), Err(crate::Error::UselessScope)));
378	}
379
380	#[test]
381	fn test_claims_validation_success() {
382		let claims = create_test_claims();
383		assert!(claims.validate().is_ok());
384	}
385
386	#[test]
387	fn test_claims_validation_no_publish_or_subscribe() {
388		let claims = Claims {
389			root: "test-path".to_string(),
390			publish: vec![],
391			subscribe: vec![],
392			expires: None,
393			issued: None,
394		};
395
396		let result = claims.validate();
397		assert!(result.is_err());
398		assert!(
399			result
400				.unwrap_err()
401				.to_string()
402				.contains("no publish or subscribe allowed; token is useless")
403		);
404	}
405
406	#[test]
407	fn test_claims_validation_only_publish() {
408		let claims = Claims {
409			root: "test-path".to_string(),
410			publish: vec!["test-pub".into()],
411			subscribe: vec![],
412			expires: None,
413			issued: None,
414		};
415
416		assert!(claims.validate().is_ok());
417	}
418
419	#[test]
420	fn test_claims_validation_only_subscribe() {
421		let claims = Claims {
422			root: "test-path".to_string(),
423			publish: vec![],
424			subscribe: vec!["test-sub".into()],
425			expires: None,
426			issued: None,
427		};
428
429		assert!(claims.validate().is_ok());
430	}
431
432	#[test]
433	fn test_claims_validation_path_not_prefix_relative_publish() {
434		let claims = Claims {
435			root: "test-path".to_string(),        // no trailing slash
436			publish: vec!["relative-pub".into()], // relative path without leading slash
437			subscribe: vec![],
438			expires: None,
439			issued: None,
440		};
441
442		let result = claims.validate();
443		assert!(result.is_ok()); // Now passes because slashes are implicitly added
444	}
445
446	#[test]
447	fn test_claims_validation_path_not_prefix_relative_subscribe() {
448		let claims = Claims {
449			root: "test-path".to_string(), // no trailing slash
450			publish: vec![],
451			subscribe: vec!["relative-sub".into()], // relative path without leading slash
452			expires: None,
453			issued: None,
454		};
455
456		let result = claims.validate();
457		assert!(result.is_ok()); // Now passes because slashes are implicitly added
458	}
459
460	#[test]
461	fn test_claims_validation_path_not_prefix_absolute_publish() {
462		let claims = Claims {
463			root: "test-path".to_string(),         // no trailing slash
464			publish: vec!["/absolute-pub".into()], // absolute path with leading slash
465			subscribe: vec![],
466			expires: None,
467			issued: None,
468		};
469
470		assert!(claims.validate().is_ok());
471	}
472
473	#[test]
474	fn test_claims_validation_path_not_prefix_absolute_subscribe() {
475		let claims = Claims {
476			root: "test-path".to_string(), // no trailing slash
477			publish: vec![],
478			subscribe: vec!["/absolute-sub".into()], // absolute path with leading slash
479			expires: None,
480			issued: None,
481		};
482
483		assert!(claims.validate().is_ok());
484	}
485
486	#[test]
487	fn test_claims_validation_path_not_prefix_empty_publish() {
488		let claims = Claims {
489			root: "test-path".to_string(), // no trailing slash
490			publish: vec!["".into()],      // empty string
491			subscribe: vec![],
492			expires: None,
493			issued: None,
494		};
495
496		assert!(claims.validate().is_ok());
497	}
498
499	#[test]
500	fn test_claims_validation_path_not_prefix_empty_subscribe() {
501		let claims = Claims {
502			root: "test-path".to_string(), // no trailing slash
503			publish: vec![],
504			subscribe: vec!["".into()], // empty string
505			expires: None,
506			issued: None,
507		};
508
509		assert!(claims.validate().is_ok());
510	}
511
512	#[test]
513	fn test_claims_validation_path_is_prefix() {
514		let claims = Claims {
515			root: "test-path".to_string(),          // with trailing slash
516			publish: vec!["relative-pub".into()],   // relative path is ok when path is prefix
517			subscribe: vec!["relative-sub".into()], // relative path is ok when path is prefix
518			expires: None,
519			issued: None,
520		};
521
522		assert!(claims.validate().is_ok());
523	}
524
525	#[test]
526	fn test_claims_validation_empty_path() {
527		let claims = Claims {
528			root: "".to_string(), // empty path
529			publish: vec!["test-pub".into()],
530			subscribe: vec![],
531			expires: None,
532			issued: None,
533		};
534
535		assert!(claims.validate().is_ok());
536	}
537
538	#[test]
539	fn test_claims_serde() {
540		let claims = create_test_claims();
541		let json = serde_json::to_string(&claims).unwrap();
542		let deserialized: Claims = serde_json::from_str(&json).unwrap();
543
544		assert_eq!(deserialized.root, claims.root);
545		assert_eq!(deserialized.publish, claims.publish);
546		assert_eq!(deserialized.subscribe, claims.subscribe);
547	}
548
549	#[test]
550	fn test_claims_default() {
551		let claims = Claims::default();
552		assert_eq!(claims.root, "");
553		assert!(claims.publish.is_empty());
554		assert!(claims.subscribe.is_empty());
555		assert_eq!(claims.expires, None);
556		assert_eq!(claims.issued, None);
557	}
558
559	fn authorize_claims(root: &str, subscribe: &[&str], publish: &[&str]) -> Claims {
560		Claims {
561			root: root.to_string(),
562			subscribe: subscribe.iter().map(|s| s.to_string()).collect(),
563			publish: publish.iter().map(|s| s.to_string()).collect(),
564			..Default::default()
565		}
566	}
567
568	#[test]
569	fn test_authorize_path_equals_root() {
570		let claims = authorize_claims("room/123", &[""], &["alice"]);
571		let permissions = claims.authorize("room/123").unwrap();
572
573		assert_eq!(permissions.subscribe, [""]);
574		assert_eq!(permissions.publish, ["alice"]);
575	}
576
577	#[test]
578	fn test_authorize_path_extends_root() {
579		// Connecting below the root consumes the matching part of each grant.
580		let claims = authorize_claims("room/123", &["bob"], &["alice"]);
581		let permissions = claims.authorize("room/123/alice").unwrap();
582
583		assert_eq!(permissions.subscribe, Vec::<String>::new());
584		assert_eq!(permissions.publish, [""]);
585	}
586
587	#[test]
588	fn test_authorize_path_is_parent_of_root() {
589		// Connecting above the root prepends it, keeping the grants anchored.
590		let claims = authorize_claims("demo", &[""], &["alice"]);
591		let permissions = claims.authorize("/").unwrap();
592
593		assert_eq!(permissions.subscribe, ["demo"]);
594		assert_eq!(permissions.publish, ["demo/alice"]);
595	}
596
597	#[test]
598	fn test_authorize_empty_root() {
599		// A root-scoped token grants everything it lists, wherever it connects.
600		let claims = authorize_claims("", &["demo"], &[]);
601		let permissions = claims.authorize("demo/room").unwrap();
602
603		assert_eq!(permissions.subscribe, [""]);
604		assert_eq!(permissions.publish, Vec::<String>::new());
605	}
606
607	#[test]
608	fn test_authorize_slashes_are_implicit() {
609		let claims = authorize_claims("/room/123/", &["/bob/"], &[]);
610		let permissions = claims.authorize("//room/123//").unwrap();
611
612		assert_eq!(permissions.subscribe, ["bob"]);
613	}
614
615	#[test]
616	fn test_authorize_respects_segment_boundaries() {
617		// "foo" must not cover "foobar".
618		let claims = authorize_claims("foo", &[""], &[""]);
619		assert!(matches!(claims.authorize("foobar"), Err(crate::Error::RootMismatch(_))));
620	}
621
622	#[test]
623	fn test_authorize_unrelated_path() {
624		let claims = authorize_claims("demo", &[""], &[""]);
625		assert!(matches!(claims.authorize("other"), Err(crate::Error::RootMismatch(_))));
626	}
627
628	#[test]
629	fn test_authorize_no_access_at_path() {
630		// The path overlaps the root, but every grant sits outside it.
631		let claims = authorize_claims("", &["demo"], &[]);
632		assert!(matches!(claims.authorize("other"), Err(crate::Error::NoAccess(_))));
633	}
634
635	#[test]
636	fn test_deserialize_string_as_vec() {
637		let json = r#"{
638			"root": "test",
639			"put": "single-publish",
640			"get": "single-subscribe"
641		}"#;
642
643		let claims: Claims = serde_json::from_str(json).unwrap();
644		assert_eq!(claims.publish, vec!["single-publish"]);
645		assert_eq!(claims.subscribe, vec!["single-subscribe"]);
646	}
647
648	#[test]
649	fn test_deserialize_vec_as_vec() {
650		let json = r#"{
651			"root": "test",
652			"put": ["pub1", "pub2"],
653			"get": ["sub1", "sub2"]
654		}"#;
655
656		let claims: Claims = serde_json::from_str(json).unwrap();
657		assert_eq!(claims.publish, vec!["pub1", "pub2"]);
658		assert_eq!(claims.subscribe, vec!["sub1", "sub2"]);
659	}
660
661	#[test]
662	fn test_deserialize_mixed() {
663		let json = r#"{
664			"root": "test",
665			"put": "single",
666			"get": ["multi1", "multi2"]
667		}"#;
668
669		let claims: Claims = serde_json::from_str(json).unwrap();
670		assert_eq!(claims.publish, vec!["single"]);
671		assert_eq!(claims.subscribe, vec!["multi1", "multi2"]);
672	}
673}