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