Skip to main content

moq_token_cli/
lib.rs

1//! The token command line surface: generate, sign, and verify tokens for moq-relay.
2//!
3//! Flatten [`Args`] into a `clap` command and call [`Args::run`]. The standalone
4//! `moq-token` binary and moq-cli's `moq token` are both built from this crate, so
5//! they stay in sync. The `moq-token` library underneath stays free of clap.
6
7use anyhow::Context;
8use clap::Subcommand;
9use std::{io, path::PathBuf};
10
11use moq_token::Algorithm;
12
13/// Generate, sign, and verify tokens for moq-relay.
14#[derive(clap::Args, Clone, Debug)]
15pub struct Args {
16	#[command(subcommand)]
17	command: Command,
18}
19
20impl Args {
21	/// Run the requested command, writing the key, token, or payload to the chosen
22	/// destination (stdout by default).
23	pub fn run(self) -> anyhow::Result<()> {
24		match self.command {
25			Command::Generate {
26				algorithm,
27				id,
28				out,
29				out_dir,
30				public,
31				public_dir,
32				root,
33				publish,
34				subscribe,
35			} => {
36				let id = match id {
37					Some(id) => moq_token::KeyId::decode(&id)?,
38					None => moq_token::KeyId::random(),
39				};
40
41				let mut key = moq_token::Key::generate(algorithm, Some(id.clone()))?;
42				if !publish.is_empty() || !subscribe.is_empty() {
43					key = key.with_scope(moq_token::Scope {
44						root,
45						publish,
46						subscribe,
47					})?;
48				}
49
50				let public_to_stdout = public.as_deref().is_some_and(is_dash);
51				let private_to_stdout = out_dir.is_none() && out.as_deref().is_none_or(is_dash);
52				if public_to_stdout && private_to_stdout {
53					anyhow::bail!(
54						"cannot write both keys to stdout; use --out/--public with a file path, or --out-dir/--public-dir"
55					);
56				}
57
58				if let Some(dir) = public_dir {
59					let path = dir.join(format!("{id}.jwk"));
60					write_key(&key.to_public()?, &path)?;
61				} else if let Some(path) = public {
62					write_key(&key.to_public()?, &path)?;
63				}
64
65				if let Some(dir) = out_dir {
66					let path = dir.join(format!("{id}.jwk"));
67					write_key(&key, &path)?;
68				} else if let Some(path) = out {
69					write_key(&key, &path)?;
70				} else {
71					let encoded = key.to_str()?;
72					println!("{encoded}");
73				}
74			}
75
76			Command::Sign {
77				key,
78				root,
79				publish,
80				subscribe,
81				expires,
82				issued,
83			} => {
84				let key = read_key(&key)?;
85
86				let payload = moq_token::Claims::default()
87					.with_root(root)
88					.with_publish(publish)
89					.with_subscribe(subscribe)
90					.with_expires(expires)
91					.with_issued(issued);
92
93				let token = key.sign(&payload)?;
94				println!("{token}");
95			}
96
97			Command::Verify { key, token } => {
98				if is_dash(&key) && is_dash(&token) {
99					anyhow::bail!("--key and --in cannot both read from stdin");
100				}
101				let key = read_key(&key)?;
102				let token = read_token(&token)?;
103				let payload = key.verify(&token)?;
104
105				println!("{payload:#?}");
106			}
107		}
108
109		Ok(())
110	}
111}
112
113#[derive(Subcommand, Clone, Debug)]
114enum Command {
115	/// Generate a new signing key.
116	///
117	/// A random key ID is assigned unless --id is specified.
118	/// Output is base64url-encoded JSON.
119	Generate {
120		/// The algorithm to use.
121		#[arg(long, default_value = "HS256")]
122		algorithm: Algorithm,
123
124		/// The key ID. Randomly generated if not provided.
125		#[arg(long)]
126		id: Option<String>,
127
128		/// Write the key to a file path. Use `-` for stdout.
129		#[arg(long)]
130		out: Option<PathBuf>,
131
132		/// Write the key to a directory as {kid}.jwk.
133		#[arg(long, conflicts_with = "out")]
134		out_dir: Option<PathBuf>,
135
136		/// Write the public key to a file path (asymmetric algorithms only). Use `-` for stdout.
137		#[arg(long)]
138		public: Option<PathBuf>,
139
140		/// Write the public key to a directory as {kid}.jwk (asymmetric algorithms only).
141		#[arg(long, conflicts_with = "public")]
142		public_dir: Option<PathBuf>,
143
144		/// Root path for the optional key scope. Only applied alongside --publish or --subscribe.
145		#[arg(long, default_value = "")]
146		root: String,
147
148		/// Publish prefixes the key may grant (repeatable).
149		#[arg(long)]
150		publish: Vec<String>,
151
152		/// Subscribe prefixes the key may grant (repeatable).
153		#[arg(long)]
154		subscribe: Vec<String>,
155	},
156
157	/// Sign a token, writing it to stdout.
158	Sign {
159		/// Path to the signing key file. Use `-` for stdin.
160		#[arg(long)]
161		key: PathBuf,
162
163		/// The root path for the token.
164		#[arg(long, default_value = "")]
165		root: String,
166
167		/// Paths the user can publish to (repeatable).
168		#[arg(long)]
169		publish: Vec<String>,
170
171		/// Paths the user can subscribe to (repeatable).
172		#[arg(long)]
173		subscribe: Vec<String>,
174
175		/// Expiration time as a unix timestamp.
176		#[arg(long, value_parser = parse_unix_timestamp)]
177		expires: Option<std::time::SystemTime>,
178
179		/// Issued-at time as a unix timestamp.
180		#[arg(long, value_parser = parse_unix_timestamp)]
181		issued: Option<std::time::SystemTime>,
182	},
183
184	/// Verify a token, writing the payload to stdout.
185	Verify {
186		/// Path to the key file. Use `-` for stdin (requires `--in` to be a file).
187		#[arg(long)]
188		key: PathBuf,
189
190		/// Path to read the token from. Use `-` for stdin.
191		#[arg(long = "in", default_value = "-")]
192		token: PathBuf,
193	},
194}
195
196fn is_dash(path: &std::path::Path) -> bool {
197	path == std::path::Path::new("-")
198}
199
200fn write_key(key: &moq_token::Key, path: &std::path::Path) -> anyhow::Result<()> {
201	if is_dash(path) {
202		println!("{}", key.to_str()?);
203		Ok(())
204	} else {
205		key.to_file(path)
206			.with_context(|| format!("failed to write key to {}", path.display()))
207	}
208}
209
210fn read_key(path: &std::path::Path) -> anyhow::Result<moq_token::Key> {
211	if is_dash(path) {
212		let contents = io::read_to_string(io::stdin())?;
213		moq_token::Key::from_str(contents.trim()).context("failed to parse key from stdin")
214	} else {
215		moq_token::Key::from_file(path).with_context(|| format!("failed to read key from {}", path.display()))
216	}
217}
218
219fn read_token(path: &std::path::Path) -> anyhow::Result<String> {
220	let raw = if is_dash(path) {
221		io::read_to_string(io::stdin())?
222	} else {
223		std::fs::read_to_string(path).with_context(|| format!("failed to read token from {}", path.display()))?
224	};
225	Ok(raw.trim().to_string())
226}
227
228fn parse_unix_timestamp(s: &str) -> anyhow::Result<std::time::SystemTime> {
229	let timestamp = s.parse::<i64>().context("expected unix timestamp")?;
230	let timestamp = timestamp.try_into().context("timestamp out of range")?;
231	// checked_add, because plain `+` panics on overflow and how far a SystemTime
232	// reaches is platform-dependent: a timespec holds i64 seconds, while Windows
233	// counts 100ns ticks and runs out far sooner.
234	std::time::SystemTime::UNIX_EPOCH
235		.checked_add(std::time::Duration::from_secs(timestamp))
236		.context("timestamp out of range")
237}
238
239#[cfg(test)]
240mod tests {
241	use super::*;
242	use clap::Parser;
243
244	/// Drive the same clap grammar the binaries expose, rather than building
245	/// `Command` directly, so the flags stay part of what's under test.
246	#[derive(Parser)]
247	struct Harness {
248		#[command(flatten)]
249		args: Args,
250	}
251
252	fn run(args: &[&str]) -> anyhow::Result<()> {
253		Harness::try_parse_from(args)?.args.run()
254	}
255
256	#[test]
257	fn generate_writes_a_usable_keypair() {
258		let dir = tempfile::tempdir().unwrap();
259		let private = dir.path().join("private.jwk");
260		let public = dir.path().join("public.jwk");
261
262		run(&[
263			"moq-token",
264			"generate",
265			// ES256 rather than an RSA algorithm: keygen dominates this test's runtime.
266			"--algorithm",
267			"ES256",
268			"--out",
269			private.to_str().unwrap(),
270			"--public",
271			public.to_str().unwrap(),
272		])
273		.unwrap();
274
275		// Sign through the CLI grammar rather than the library, so the value parsers
276		// behind --expires / --issued are covered too.
277		run(&[
278			"moq-token",
279			"sign",
280			"--key",
281			private.to_str().unwrap(),
282			"--root",
283			"demo",
284			"--publish",
285			"alice",
286			"--issued",
287			"1700000000",
288			"--expires",
289			"4102444800",
290		])
291		.unwrap();
292
293		// What the relay actually does with these two files: the public half has to
294		// verify what the private half signed. `sign` only prints to stdout, so the
295		// token itself comes from the library.
296		let token = moq_token::Key::from_file(&private)
297			.unwrap()
298			.sign(&moq_token::Claims::default().with_root("demo").with_publish(["alice"]))
299			.unwrap();
300		let path = dir.path().join("alice.jwt");
301		std::fs::write(&path, &token).unwrap();
302
303		run(&[
304			"moq-token",
305			"verify",
306			"--key",
307			public.to_str().unwrap(),
308			"--in",
309			path.to_str().unwrap(),
310		])
311		.unwrap();
312	}
313
314	#[test]
315	fn generate_to_a_directory_names_the_file_after_the_kid() {
316		let dir = tempfile::tempdir().unwrap();
317
318		run(&["moq-token", "generate", "--out-dir", dir.path().to_str().unwrap()]).unwrap();
319
320		let written: Vec<_> = std::fs::read_dir(dir.path())
321			.unwrap()
322			.map(|e| e.unwrap().path())
323			.collect();
324		assert_eq!(written.len(), 1, "expected exactly one key, got {written:?}");
325		let key = moq_token::Key::from_file(&written[0]).unwrap();
326		let kid = key.kid.as_ref().expect("generate assigns a kid");
327		assert_eq!(written[0].file_name().unwrap().to_str().unwrap(), format!("{kid}.jwk"));
328	}
329
330	// Both halves on stdout would interleave into one unparseable blob, so it's
331	// rejected up front rather than written.
332	#[test]
333	fn both_keys_to_stdout_is_rejected() {
334		let err = run(&["moq-token", "generate", "--algorithm", "ES256", "--public", "-"]).unwrap_err();
335		assert!(err.to_string().contains("cannot write both keys to stdout"), "{err}");
336	}
337
338	#[test]
339	fn both_inputs_from_stdin_is_rejected() {
340		let err = run(&["moq-token", "verify", "--key", "-", "--in", "-"]).unwrap_err();
341		assert!(err.to_string().contains("cannot both read from stdin"), "{err}");
342	}
343
344	#[test]
345	fn timestamp_before_the_epoch_is_rejected() {
346		assert!(parse_unix_timestamp("-1").is_err());
347		assert!(parse_unix_timestamp("not-a-number").is_err());
348	}
349
350	// Whether the largest parseable timestamp is representable depends on the
351	// platform's SystemTime, so assert only that it never panics.
352	#[test]
353	fn timestamp_at_the_maximum_does_not_panic() {
354		let _ = parse_unix_timestamp(&i64::MAX.to_string());
355	}
356}