Skip to main content

moq_auth/
claims.rs

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