Skip to main content

moq_auth/
request.rs

1use serde::{Deserialize, Serialize};
2use serde_with::base64::{Base64, UrlSafe};
3use serde_with::formats::Unpadded;
4use serde_with::{DurationSecondsWithFrac, TimestampSeconds, serde_as};
5use std::net::SocketAddr;
6use std::time::{Duration, SystemTime};
7
8use crate::lease::Reason;
9
10/// Everything a relay knows about a session, sent to the auth server on every event.
11///
12/// Nothing is parsed on the relay's behalf: the server keys policy on the raw
13/// [`path`](Self::path), [`query`](Self::query), and [`token`](Self::token), so no
14/// query parameter is special and a credential can be whatever the server understands. The same shape carries
15/// every [`Event`]; an `end` adds what the session did.
16#[serde_with::skip_serializing_none]
17#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
18#[non_exhaustive]
19pub struct Request {
20	/// Random 128-bit hex, unique per session; the key every event for it shares.
21	pub id: String,
22
23	/// Which lifecycle event this is, with what an `end` carries.
24	#[serde(flatten)]
25	pub event: Event,
26
27	/// The operator's name for the relay asking.
28	pub node: String,
29
30	/// How the session reached the relay.
31	pub transport: Transport,
32
33	/// The peer's socket address, absent on a transport without one (a unix socket).
34	pub remote: Option<SocketAddr>,
35
36	/// The relay's socket address the session arrived on, absent likewise.
37	pub local: Option<SocketAddr>,
38
39	/// The SNI the client presented, when the transport carried TLS.
40	pub server_name: Option<String>,
41
42	/// The negotiated application protocol, including the moq version.
43	pub alpn: Option<String>,
44
45	/// The path exactly as dialed.
46	pub path: String,
47
48	/// The raw query string, without the leading `?`.
49	pub query: Option<String>,
50
51	/// The credential a moq-transport client presented in its SETUP.
52	pub token: Option<Token>,
53
54	/// The direction the client declared at SETUP; absent means both.
55	pub role: Option<Role>,
56
57	/// The verified client certificate, when one was presented.
58	pub tls: Option<Peer>,
59}
60
61impl Request {
62	/// A `connect` for a fresh session, minting a random 128-bit hex id.
63	///
64	/// Set the remaining fields on the returned value; the struct is
65	/// `#[non_exhaustive]`, so this stays the way to build one as fields are added.
66	pub fn new(node: impl Into<String>, transport: Transport, path: impl Into<String>) -> Self {
67		let mut bytes = [0u8; 16];
68		aws_lc_rs::rand::fill(&mut bytes).expect("failed to generate a session id");
69		let id: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
70		Self {
71			id,
72			event: Event::Connect,
73			node: node.into(),
74			transport,
75			remote: None,
76			local: None,
77			server_name: None,
78			alpn: None,
79			path: path.into(),
80			query: None,
81			token: None,
82			role: None,
83			tls: None,
84		}
85	}
86}
87
88/// A credential from a moq-transport SETUP's `AUTHORIZATION TOKEN` option, unparsed.
89#[serde_as]
90#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
91pub struct Token {
92	/// The moq-transport Token Type, naming how [`value`](Self::value) is encoded.
93	pub kind: u64,
94	/// The token bytes, base64url without padding on the wire.
95	#[serde_as(as = "Base64<UrlSafe, Unpadded>")]
96	pub value: Vec<u8>,
97}
98
99impl Token {
100	/// Token Type 0: a format negotiated out of band; `moq auth serve` reads it as a JWT.
101	pub const OUT_OF_BAND: u64 = 0x0;
102	/// Token Type 1: a Common Access Token.
103	pub const CAT: u64 = 0x1;
104}
105
106/// The lifecycle moment a [`Request`] reports.
107#[serde_as]
108#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
109#[serde(tag = "event", rename_all = "lowercase")]
110pub enum Event {
111	/// A session was accepted and asks to be admitted.
112	Connect,
113	/// The grant asked to be re-checked on its cadence.
114	Revalidate,
115	/// The session closed.
116	End {
117		/// Why it closed.
118		reason: Reason,
119		/// How long it was admitted.
120		#[serde_as(as = "DurationSecondsWithFrac<f64>")]
121		duration: Duration,
122		/// What it moved.
123		bytes: Bytes,
124	},
125}
126
127/// How a session reached the relay. The names match `moq_tokio::server::Transport`,
128/// plus `http` for the relay's one-shot HTTP routes.
129#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
130#[serde(rename_all = "lowercase")]
131pub enum Transport {
132	/// QUIC, either directly or through WebTransport over HTTP/3.
133	Quic,
134	/// An Iroh QUIC connection.
135	Iroh,
136	/// A WebSocket connection using qmux framing.
137	WebSocket,
138	/// A plaintext TCP connection using qmux framing.
139	Tcp,
140	/// A Unix domain socket using qmux framing.
141	Unix,
142	/// A one-shot HTTP request on the relay's web listener (`/fetch`, `/announced`),
143	/// admitted and ended within the request.
144	Http,
145}
146
147impl Transport {
148	/// The stable lowercase name, the same one the wire carries.
149	pub const fn as_str(self) -> &'static str {
150		match self {
151			Self::Quic => "quic",
152			Self::Iroh => "iroh",
153			Self::WebSocket => "websocket",
154			Self::Tcp => "tcp",
155			Self::Unix => "unix",
156			Self::Http => "http",
157		}
158	}
159}
160
161impl std::fmt::Display for Transport {
162	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
163		f.write_str(self.as_str())
164	}
165}
166
167/// The single direction a client declared at SETUP.
168#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
169#[serde(rename_all = "lowercase")]
170pub enum Role {
171	/// The client will publish; the relay consumes.
172	Publisher,
173	/// The client will subscribe; the relay publishes.
174	Subscriber,
175}
176
177/// The verified client certificate a session presented, as facts for the server to
178/// decide on. Presenting one admits nothing by itself.
179#[serde_as]
180#[serde_with::skip_serializing_none]
181#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
182pub struct Peer {
183	/// The first SAN DNS name, else the CN, else the fingerprint, so it is never empty.
184	///
185	/// Those three sources fold into one string a server cannot tell apart; match on
186	/// [`fingerprint`](Self::fingerprint) when identity must be exact.
187	pub name: String,
188	/// SHA-256 of the leaf certificate, hex.
189	pub fingerprint: String,
190	/// The certificate's notAfter.
191	#[serde_as(as = "Option<TimestampSeconds<i64>>")]
192	pub expires: Option<SystemTime>,
193	/// The issuer's distinguished name.
194	pub issuer: String,
195}
196
197/// Byte totals for a session, both directions from the relay's point of view.
198#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
199pub struct Bytes {
200	/// Bytes the relay sent to the peer.
201	pub sent: u64,
202	/// Bytes the relay received from the peer.
203	pub received: u64,
204}
205
206#[cfg(test)]
207mod tests {
208	use super::*;
209
210	fn request() -> Request {
211		let mut request = Request::new("relay-1", Transport::Quic, "/demo/room");
212		request.id = "00ff".into();
213		request.remote = Some("203.0.113.9:4433".parse().unwrap());
214		request.local = Some("[::1]:443".parse().unwrap());
215		request.server_name = Some("relay.example".into());
216		request.alpn = Some("moq-lite-05".into());
217		request.query = Some("jwt=abc".into());
218		request.role = Some(Role::Publisher);
219		request.tls = Some(Peer {
220			name: "edge0".into(),
221			fingerprint: "ab".repeat(32),
222			expires: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(4_102_444_800)),
223			issuer: "CN=cluster".into(),
224		});
225		request
226	}
227
228	#[test]
229	fn connect_round_trips_flat() {
230		let request = request();
231		let json = serde_json::to_value(&request).unwrap();
232		assert_eq!(json["event"], "connect");
233		assert_eq!(json["transport"], "quic");
234		assert_eq!(json["role"], "publisher");
235		assert_eq!(json["remote"], "203.0.113.9:4433");
236		assert_eq!(json["tls"]["expires"], 4_102_444_800_i64);
237		assert!(json.get("reason").is_none());
238		assert_eq!(serde_json::from_value::<Request>(json).unwrap(), request);
239	}
240
241	#[test]
242	fn end_carries_its_facts_beside_the_rest() {
243		let mut request = request();
244		request.event = Event::End {
245			reason: Reason::Session("disconnected".into()),
246			duration: Duration::from_millis(1500),
247			bytes: Bytes { sent: 10, received: 20 },
248		};
249		let json = serde_json::to_value(&request).unwrap();
250		assert_eq!(json["event"], "end");
251		assert_eq!(json["reason"], "disconnected");
252		assert_eq!(json["duration"], 1.5);
253		assert_eq!(json["bytes"]["sent"], 10);
254		assert_eq!(serde_json::from_value::<Request>(json).unwrap(), request);
255	}
256
257	/// The exact bytes `js/auth/src/interop.test.ts` parses, so both languages read
258	/// one wire shape.
259	#[test]
260	fn end_serializes_to_the_cross_language_vector() {
261		let mut request = Request::new("relay-1", Transport::WebSocket, "/demo/room");
262		request.id = "00ff".into();
263		request.remote = Some("203.0.113.9:4433".parse().unwrap());
264		request.query = Some("jwt=abc".into());
265		request.event = Event::End {
266			reason: Reason::Expired,
267			duration: Duration::from_millis(1500),
268			bytes: Bytes { sent: 10, received: 20 },
269		};
270		assert_eq!(
271			serde_json::to_string(&request).unwrap(),
272			r#"{"id":"00ff","event":"end","reason":"expired","duration":1.5,"bytes":{"sent":10,"received":20},"node":"relay-1","transport":"websocket","remote":"203.0.113.9:4433","path":"/demo/room","query":"jwt=abc"}"#
273		);
274
275		request.event = Event::End {
276			reason: Reason::Invalid,
277			duration: Duration::from_millis(1500),
278			bytes: Bytes { sent: 10, received: 20 },
279		};
280		assert_eq!(
281			serde_json::to_string(&request).unwrap(),
282			r#"{"id":"00ff","event":"end","reason":"invalid","duration":1.5,"bytes":{"sent":10,"received":20},"node":"relay-1","transport":"websocket","remote":"203.0.113.9:4433","path":"/demo/room","query":"jwt=abc"}"#
283		);
284	}
285
286	/// The exact bytes `js/auth/src/contract.test.ts` parses: the value is base64url, so
287	/// bytes that are not text survive the JSON unchanged.
288	#[test]
289	fn a_setup_token_serializes_as_base64url() {
290		let mut request = Request::new("relay-1", Transport::Quic, "/demo/room");
291		request.id = "00ff".into();
292		request.token = Some(Token {
293			kind: Token::CAT,
294			value: vec![0x00, 0xfb, 0xff],
295		});
296		let json = serde_json::to_string(&request).unwrap();
297		assert_eq!(
298			json,
299			r#"{"id":"00ff","event":"connect","node":"relay-1","transport":"quic","path":"/demo/room","token":{"kind":1,"value":"APv_"}}"#
300		);
301		assert_eq!(serde_json::from_str::<Request>(&json).unwrap(), request);
302
303		// `js/auth` refuses the same malformed values.
304		for value in ["A", "AB", "APv_A", "AP+/"] {
305			let json = format!(r#"{{"kind":0,"value":"{value}"}}"#);
306			assert!(serde_json::from_str::<Token>(&json).is_err(), "{value}");
307		}
308		for value in ["", "AA", "AAA", "AAAA", "AQ", "AAE"] {
309			let json = format!(r#"{{"kind":0,"value":"{value}"}}"#);
310			assert!(serde_json::from_str::<Token>(&json).is_ok(), "{value}");
311		}
312	}
313
314	#[test]
315	fn a_unix_session_has_no_addresses() {
316		let request = Request::new("relay-1", Transport::Unix, "");
317		let json = serde_json::to_value(&request).unwrap();
318		assert!(json.get("remote").is_none());
319		assert_eq!(json["transport"], "unix");
320		assert_eq!(serde_json::from_value::<Request>(json).unwrap(), request);
321	}
322
323	#[test]
324	fn new_mints_a_128_bit_hex_id() {
325		let a = Request::new("relay-1", Transport::Quic, "/");
326		let b = Request::new("relay-1", Transport::Quic, "/");
327		assert_eq!(a.id.len(), 32);
328		assert!(a.id.chars().all(|c| c.is_ascii_hexdigit()), "{}", a.id);
329		assert_ne!(a.id, b.id);
330	}
331}