1use anyhow::Context;
8use clap::Subcommand;
9use std::{io, path::PathBuf};
10
11use moq_token::Algorithm;
12
13#[derive(clap::Args, Clone, Debug)]
15pub struct Args {
16 #[command(subcommand)]
17 command: Command,
18}
19
20impl Args {
21 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 {
120 #[arg(long, default_value = "HS256")]
122 algorithm: Algorithm,
123
124 #[arg(long)]
126 id: Option<String>,
127
128 #[arg(long)]
130 out: Option<PathBuf>,
131
132 #[arg(long, conflicts_with = "out")]
134 out_dir: Option<PathBuf>,
135
136 #[arg(long)]
138 public: Option<PathBuf>,
139
140 #[arg(long, conflicts_with = "public")]
142 public_dir: Option<PathBuf>,
143
144 #[arg(long, default_value = "")]
146 root: String,
147
148 #[arg(long)]
150 publish: Vec<String>,
151
152 #[arg(long)]
154 subscribe: Vec<String>,
155 },
156
157 Sign {
159 #[arg(long)]
161 key: PathBuf,
162
163 #[arg(long, default_value = "")]
165 root: String,
166
167 #[arg(long)]
169 publish: Vec<String>,
170
171 #[arg(long)]
173 subscribe: Vec<String>,
174
175 #[arg(long, value_parser = parse_unix_timestamp)]
177 expires: Option<std::time::SystemTime>,
178
179 #[arg(long, value_parser = parse_unix_timestamp)]
181 issued: Option<std::time::SystemTime>,
182 },
183
184 Verify {
186 #[arg(long)]
188 key: PathBuf,
189
190 #[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 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 #[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 "--algorithm",
267 "ES256",
268 "--out",
269 private.to_str().unwrap(),
270 "--public",
271 public.to_str().unwrap(),
272 ])
273 .unwrap();
274
275 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 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 #[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 #[test]
353 fn timestamp_at_the_maximum_does_not_panic() {
354 let _ = parse_unix_timestamp(&i64::MAX.to_string());
355 }
356}