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.
12pub(crate) fn 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	/// Refuse a grant that admits nothing, asks to be revalidated without a bound or
71	/// at no interval, or has already expired. A few seconds of clock skew are
72	/// tolerated so an auth server whose clock runs behind still admits.
73	pub fn validate(&self) -> crate::Result<()> {
74		if self.publish.is_empty() && self.subscribe.is_empty() {
75			return Err(crate::Error::UselessGrant);
76		}
77		if self.revalidate.is_some() && self.expires.is_none() {
78			return Err(crate::Error::UnboundedRevalidate);
79		}
80		// A zero cadence would have the relay re-check in a tight loop.
81		if self.revalidate.is_some_and(|cadence| cadence.is_zero()) {
82			return Err(crate::Error::ZeroRevalidate);
83		}
84		if self.expires.is_some_and(|expires| until(expires).is_zero()) {
85			return Err(crate::Error::GrantExpired);
86		}
87		Ok(())
88	}
89}
90
91#[cfg(test)]
92mod tests {
93	use super::*;
94
95	fn patterns(texts: &[&str]) -> Patterns {
96		texts.iter().map(|text| text.parse().unwrap()).collect()
97	}
98
99	#[test]
100	fn round_trips_in_seconds() {
101		let grant = Grant {
102			publish: patterns(&["alice/**"]),
103			subscribe: patterns(&["**"]),
104			root: Some("pid/room".into()),
105			expires: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(4_102_444_800)),
106			revalidate: Some(Duration::from_secs(60)),
107			tier: Some("websocket".into()),
108			peer: true,
109		};
110		let json = serde_json::to_value(&grant).unwrap();
111		assert_eq!(json["expires"], 4_102_444_800_i64);
112		assert_eq!(json["revalidate"], 60);
113		assert_eq!(json["publish"], serde_json::json!(["alice/**"]));
114		assert_eq!(json["peer"], true);
115		assert_eq!(serde_json::from_value::<Grant>(json).unwrap(), grant);
116	}
117
118	/// The exact bytes `js/auth/src/interop.test.ts` parses, so both languages read
119	/// one wire shape.
120	#[test]
121	fn serializes_to_the_cross_language_vector() {
122		let grant = Grant {
123			publish: patterns(&["alice/**"]),
124			subscribe: patterns(&["**"]),
125			root: Some("pid/room".into()),
126			expires: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(4_102_444_800)),
127			revalidate: Some(Duration::from_secs(60)),
128			tier: Some("websocket".into()),
129			peer: true,
130		};
131		assert_eq!(
132			serde_json::to_string(&grant).unwrap(),
133			r#"{"publish":["alice/**"],"subscribe":["**"],"root":"pid/room","expires":4102444800,"revalidate":60,"tier":"websocket","peer":true}"#
134		);
135	}
136
137	#[test]
138	fn empty_fields_are_omitted_and_defaulted() {
139		let grant = Grant::new(patterns(&["**"]), Patterns::new());
140		assert_eq!(serde_json::to_string(&grant).unwrap(), r#"{"publish":["**"]}"#);
141		assert_eq!(serde_json::from_str::<Grant>(r#"{"publish":["**"]}"#).unwrap(), grant);
142	}
143
144	#[test]
145	fn validate_refuses_nothing_unbounded_and_expired() {
146		assert!(matches!(Grant::default().validate(), Err(crate::Error::UselessGrant)));
147
148		let mut grant = Grant::new(patterns(&["**"]), Patterns::new());
149		grant.validate().unwrap();
150
151		grant.revalidate = Some(Duration::from_secs(1));
152		assert!(matches!(grant.validate(), Err(crate::Error::UnboundedRevalidate)));
153
154		grant.expires = Some(SystemTime::now() - Duration::from_secs(1));
155		grant.validate().unwrap();
156
157		grant.expires = Some(SystemTime::now() - CLOCK_SKEW - Duration::from_secs(1));
158		assert!(matches!(grant.validate(), Err(crate::Error::GrantExpired)));
159
160		grant.expires = Some(SystemTime::now() + Duration::from_secs(60));
161		grant.validate().unwrap();
162
163		grant.revalidate = Some(Duration::ZERO);
164		assert!(matches!(grant.validate(), Err(crate::Error::ZeroRevalidate)));
165	}
166}