Skip to main content

moq_auth/
claims.rs

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