Skip to main content

moq_auth/
grant.rs

1use moq_pattern::Patterns;
2use serde::{Deserialize, Serialize};
3use serde_with::{DurationSeconds, TimestampSeconds, serde_as};
4use std::time::{Duration, SystemTime};
5
6/// A grant that expired this recently still stands: the auth server's clock may run behind.
7pub(crate) const CLOCK_SKEW: Duration = Duration::from_secs(5);
8
9/// How long until `at`. A deadline up to [`CLOCK_SKEW`] in the past still has the
10/// remaining window; anything older is zero. Future deadlines are unchanged, so a
11/// grant that expires in ten seconds still expires in ten seconds.
12fn until(at: SystemTime) -> Duration {
13	match at.duration_since(SystemTime::now()) {
14		Ok(remaining) => remaining,
15		Err(late) => CLOCK_SKEW.saturating_sub(late.duration()),
16	}
17}
18
19/// What a session may do, as the auth server answered.
20///
21/// A 2xx carrying one of these admits; anything else refuses. A grant that names
22/// nothing is a refusal too, and one that asks to be revalidated must say when it
23/// expires, so an outage always has a bound the server chose. [`validate`](Self::validate)
24/// checks these once at the boundary.
25#[serde_as]
26#[serde_with::skip_serializing_none]
27#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
28#[serde(default)]
29#[non_exhaustive]
30pub struct Grant {
31	/// Patterns the session may publish, relative to the root.
32	#[serde(skip_serializing_if = "Patterns::is_empty")]
33	pub publish: Patterns,
34
35	/// Patterns the session may subscribe to, relative to the root.
36	#[serde(skip_serializing_if = "Patterns::is_empty")]
37	pub subscribe: Patterns,
38
39	/// The path the patterns are relative to, replacing the dialed one. This is how a
40	/// server aliases a slug to a canonical id. Absent means the dialed path.
41	pub root: Option<String>,
42
43	/// When the session closes, as unix seconds.
44	#[serde_as(as = "Option<TimestampSeconds<i64>>")]
45	pub expires: Option<SystemTime>,
46
47	/// How long until the relay asks again, in seconds.
48	#[serde_as(as = "Option<DurationSeconds<u64>>")]
49	pub revalidate: Option<Duration>,
50
51	/// An opaque label handed to stats, so traffic can be bucketed.
52	pub tier: Option<String>,
53
54	/// The session is a cluster peer (another relay): what it announces entered
55	/// the cluster elsewhere, not here.
56	#[serde(skip_serializing_if = "std::ops::Not::not")]
57	pub peer: bool,
58}
59
60impl Grant {
61	/// A grant of these patterns and nothing else.
62	pub fn new(publish: Patterns, subscribe: Patterns) -> Self {
63		Self {
64			publish,
65			subscribe,
66			..Default::default()
67		}
68	}
69
70	/// Snapshot the expiry on Tokio's clock, allowing five seconds of past clock skew.
71	#[cfg(feature = "tokio")]
72	pub fn deadline(&self) -> Option<tokio::time::Instant> {
73		self.expires.map(|at| tokio::time::Instant::now() + until(at))
74	}
75
76	/// Refuse a grant that admits nothing, asks to be revalidated without a bound or
77	/// at no interval, or has already expired. A few seconds of clock skew are
78	/// tolerated so an auth server whose clock runs behind still admits.
79	pub fn validate(&self) -> crate::Result<()> {
80		if self.publish.is_empty() && self.subscribe.is_empty() {
81			return Err(crate::Error::UselessGrant);
82		}
83		if self.revalidate.is_some() && self.expires.is_none() {
84			return Err(crate::Error::UnboundedRevalidate);
85		}
86		// A zero cadence would have the relay re-check in a tight loop.
87		if self.revalidate.is_some_and(|cadence| cadence.is_zero()) {
88			return Err(crate::Error::ZeroRevalidate);
89		}
90		if self.expires.is_some_and(|expires| until(expires).is_zero()) {
91			return Err(crate::Error::GrantExpired);
92		}
93		Ok(())
94	}
95}
96
97#[cfg(test)]
98mod tests {
99	use super::*;
100
101	fn patterns(texts: &[&str]) -> Patterns {
102		texts.iter().map(|text| text.parse().unwrap()).collect()
103	}
104
105	#[test]
106	fn round_trips_in_seconds() {
107		let grant = Grant {
108			publish: patterns(&["alice/**"]),
109			subscribe: patterns(&["**"]),
110			root: Some("pid/room".into()),
111			expires: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(4_102_444_800)),
112			revalidate: Some(Duration::from_secs(60)),
113			tier: Some("websocket".into()),
114			peer: true,
115		};
116		let json = serde_json::to_value(&grant).unwrap();
117		assert_eq!(json["expires"], 4_102_444_800_i64);
118		assert_eq!(json["revalidate"], 60);
119		assert_eq!(json["publish"], serde_json::json!(["alice/**"]));
120		assert_eq!(json["peer"], true);
121		assert_eq!(serde_json::from_value::<Grant>(json).unwrap(), grant);
122	}
123
124	/// The exact bytes `js/auth/src/interop.test.ts` parses, so both languages read
125	/// one wire shape.
126	#[test]
127	fn serializes_to_the_cross_language_vector() {
128		let grant = Grant {
129			publish: patterns(&["alice/**"]),
130			subscribe: patterns(&["**"]),
131			root: Some("pid/room".into()),
132			expires: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(4_102_444_800)),
133			revalidate: Some(Duration::from_secs(60)),
134			tier: Some("websocket".into()),
135			peer: true,
136		};
137		assert_eq!(
138			serde_json::to_string(&grant).unwrap(),
139			r#"{"publish":["alice/**"],"subscribe":["**"],"root":"pid/room","expires":4102444800,"revalidate":60,"tier":"websocket","peer":true}"#
140		);
141	}
142
143	#[test]
144	fn empty_fields_are_omitted_and_defaulted() {
145		let grant = Grant::new(patterns(&["**"]), Patterns::new());
146		assert_eq!(serde_json::to_string(&grant).unwrap(), r#"{"publish":["**"]}"#);
147		assert_eq!(serde_json::from_str::<Grant>(r#"{"publish":["**"]}"#).unwrap(), grant);
148	}
149
150	#[test]
151	fn validate_refuses_nothing_unbounded_and_expired() {
152		assert!(matches!(Grant::default().validate(), Err(crate::Error::UselessGrant)));
153
154		let mut grant = Grant::new(patterns(&["**"]), Patterns::new());
155		grant.validate().unwrap();
156
157		grant.revalidate = Some(Duration::from_secs(1));
158		assert!(matches!(grant.validate(), Err(crate::Error::UnboundedRevalidate)));
159
160		grant.expires = Some(SystemTime::now() - Duration::from_secs(1));
161		grant.validate().unwrap();
162
163		grant.expires = Some(SystemTime::now() - CLOCK_SKEW - Duration::from_secs(1));
164		assert!(matches!(grant.validate(), Err(crate::Error::GrantExpired)));
165
166		grant.expires = Some(SystemTime::now() + Duration::from_secs(60));
167		grant.validate().unwrap();
168
169		grant.revalidate = Some(Duration::ZERO);
170		assert!(matches!(grant.validate(), Err(crate::Error::ZeroRevalidate)));
171	}
172}