From 05a77cc505e2ef54abb4c44f186ff52e2335eae9 Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 31 Dec 2025 00:26:01 +0100 Subject: [PATCH 01/15] Begin JWK set implementation --- rs/moq-relay/Cargo.toml | 2 +- rs/moq-relay/src/auth.rs | 152 +++++++++++++++++++++++++--- rs/moq-relay/src/connection.rs | 3 +- rs/moq-relay/src/main.rs | 3 +- rs/moq-relay/src/web.rs | 2 +- rs/moq-token/Cargo.toml | 4 + rs/moq-token/src/lib.rs | 2 + rs/moq-token/src/set.rs | 177 +++++++++++++++++++++++++++++++++ 8 files changed, 329 insertions(+), 16 deletions(-) create mode 100644 rs/moq-token/src/set.rs diff --git a/rs/moq-relay/Cargo.toml b/rs/moq-relay/Cargo.toml index 64cf806ec..447a20e9e 100644 --- a/rs/moq-relay/Cargo.toml +++ b/rs/moq-relay/Cargo.toml @@ -21,7 +21,7 @@ futures = "0.3" http-body = "1" moq-lite = { workspace = true, features = ["serde"] } moq-native = { workspace = true, features = ["aws-lc-rs"] } -moq-token = { workspace = true } +moq-token = { workspace = true, features = ["jwks-loader"] } rustls = { version = "0.23", features = [ "aws-lc-rs", ], default-features = false } diff --git a/rs/moq-relay/src/auth.rs b/rs/moq-relay/src/auth.rs index 52259860d..2c42df6bf 100644 --- a/rs/moq-relay/src/auth.rs +++ b/rs/moq-relay/src/auth.rs @@ -1,8 +1,9 @@ -use std::sync::Arc; - use axum::http; use moq_lite::{AsPath, Path, PathOwned}; +use moq_token::{KeyProvider, KeySet, KeySetLoader}; use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use std::time::Duration; #[derive(thiserror::Error, Debug, Clone)] pub enum AuthError { @@ -36,8 +37,18 @@ impl axum::response::IntoResponse for AuthError { pub struct AuthConfig { /// The root authentication key. /// If present, all paths will require a token unless they are in the public list. - #[arg(long = "auth-key", env = "MOQ_AUTH_KEY")] - pub key: Option, + #[arg(long = "auth-key", env = "MOQ_AUTH_KEY", conflicts_with = "jwks_uri")] + key: Option, + + /// The URI to the JWK set. + /// If present, all paths will require a token that can be validated with the given JWK set + /// unless they are in the public list. + #[arg(long = "jwks-uri", env = "MOQ_AUTH_JWKS_URI", conflicts_with = "key")] + pub jwks_uri: Option, + + /// How often to refresh the JWK set (in seconds), if not provided the JWKs won't be refreshed. + #[arg(long = "jwks-refresh-interval", env = "MOQ_AUTH_JWKS_REFRESH_INTERVAL")] + pub jwks_refresh_interval: Option, /// The prefix that will be public for reading and writing. /// If present, unauthorized users will be able to read and write to this prefix ONLY. @@ -60,18 +71,102 @@ pub struct AuthToken { pub cluster: bool, } -#[derive(Clone)] +const JWKS_REFRESH_ERROR_INTERVAL: Duration = Duration::from_mins(5); + pub struct Auth { - key: Option>, + key: Option>>, public: Option, + refresh_task: Option>, +} + +impl Drop for Auth { + fn drop(&mut self) { + if let Some(handle) = &self.refresh_task { + handle.abort(); + } + } } impl Auth { + fn compare_key_sets(previous: KeySet, new: KeySet) { + for new_key in new.keys { + if new_key.kid.is_some() { + if previous.keys.iter().find(|k| k.kid == new_key.kid).is_none() { + tracing::info!("Found new JWT key \"{}\"", new_key.kid.as_deref().unwrap()) + } + } + } + } + + async fn refresh(loader: &KeySetLoader) -> anyhow::Result<()> { + let previous = loader.get_keys(); + + let result = loader.refresh().await; + if let Ok(()) = result { + if let (Ok(previous), Ok(new)) = (previous, loader.get_keys()) { + Self::compare_key_sets(previous, new); + } + } + result + } + + fn spawn_refresh_task(interval: Duration, loader: Arc) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + loop { + if let Err(e) = Self::refresh(loader.as_ref()).await { + if interval > JWKS_REFRESH_ERROR_INTERVAL * 2 { + tracing::error!( + "failed to load JWKS, will retry in {} seconds: {:?}", + JWKS_REFRESH_ERROR_INTERVAL.as_secs(), + e + ); + tokio::time::sleep(JWKS_REFRESH_ERROR_INTERVAL).await; + + if let Err(e) = Self::refresh(loader.as_ref()).await { + tracing::error!("failed to load JWKS again, giving up this time: {:?}", e); + } else { + tracing::info!("successfully loaded JWKS on the second try"); + } + } else { + // Don't retry because the next refresh is going to happen very soon + tracing::error!("failed to refresh JWKS: {:?}", e); + } + } + + tokio::time::sleep(interval).await; + } + }) + } + pub fn new(config: AuthConfig) -> anyhow::Result { - let key = match config.key.as_deref() { - Some(path) => Some(moq_token::Key::from_file(path)?), - None => None, - }; + let mut refresh_task = None; + + let key: Option>> = + match (config.key.as_deref(), config.jwks_uri.as_deref()) { + (Some(key), None) => Some(Box::new(Arc::new(KeySet { + keys: vec![Arc::new(moq_token::Key::from_file(key.to_string())?)], + }))), + (None, Some(jwks_uri)) => { + let loader = Arc::new(KeySetLoader::new(jwks_uri.to_string())); + + refresh_task = match config.jwks_refresh_interval { + Some(refresh_interval_secs) => { + // Spawn async task to refresh periodically + Some(Self::spawn_refresh_task( + Duration::from_secs(refresh_interval_secs), + loader.clone(), + )) + } + None => None, + }; + + // TODO Probably the best would be to crash when the initial load fails + + Some(Box::new(loader)) + } + (Some(_), Some(_)) => anyhow::bail!("Cannot provide both key and jwks_uri, choose one!"), + (None, None) => None, + }; let public = config.public; @@ -82,8 +177,9 @@ impl Auth { } Ok(Self { - key: key.map(Arc::new), + key, public: public.map(|p| p.as_path().to_owned()), + refresh_task, }) } @@ -119,7 +215,7 @@ impl Auth { Some(suffix) => suffix, }; - // If a more specific path is is provided, reduce the permissions. + // If a more specific path is provided, reduce the permissions. let subscribe = claims .subscribe .into_iter() @@ -173,6 +269,8 @@ mod tests { // Test anonymous access to /anon path let auth = Auth::new(AuthConfig { key: None, + jwks_uri: None, + jwks_refresh_interval: None, public: Some("anon".to_string()), })?; @@ -196,6 +294,8 @@ mod tests { // Test fully public access (public = "") let auth = Auth::new(AuthConfig { key: None, + jwks_uri: None, + jwks_refresh_interval: None, public: Some("".to_string()), })?; @@ -213,6 +313,8 @@ mod tests { // Test anonymous access denied for wrong prefix let auth = Auth::new(AuthConfig { key: None, + jwks_uri: None, + jwks_refresh_interval: None, public: Some("anon".to_string()), })?; @@ -228,6 +330,8 @@ mod tests { let (key_file, _) = create_test_key()?; let auth = Auth::new(AuthConfig { key: Some(key_file.path().to_string_lossy().to_string()), + jwks_uri: None, + jwks_refresh_interval: None, public: None, })?; @@ -242,6 +346,8 @@ mod tests { fn test_token_provided_but_no_key_configured() -> anyhow::Result<()> { let auth = Auth::new(AuthConfig { key: None, + jwks_uri: None, + jwks_refresh_interval: None, public: Some("anon".to_string()), })?; @@ -257,6 +363,8 @@ mod tests { let (key_file, key) = create_test_key()?; let auth = Auth::new(AuthConfig { key: Some(key_file.path().to_string_lossy().to_string()), + jwks_uri: None, + jwks_refresh_interval: None, public: None, })?; @@ -283,6 +391,8 @@ mod tests { let (key_file, key) = create_test_key()?; let auth = Auth::new(AuthConfig { key: Some(key_file.path().to_string_lossy().to_string()), + jwks_uri: None, + jwks_refresh_interval: None, public: None, })?; @@ -307,6 +417,8 @@ mod tests { let (key_file, key) = create_test_key()?; let auth = Auth::new(AuthConfig { key: Some(key_file.path().to_string_lossy().to_string()), + jwks_uri: None, + jwks_refresh_interval: None, public: None, })?; @@ -333,6 +445,8 @@ mod tests { let (key_file, key) = create_test_key()?; let auth = Auth::new(AuthConfig { key: Some(key_file.path().to_string_lossy().to_string()), + jwks_uri: None, + jwks_refresh_interval: None, public: None, })?; @@ -357,6 +471,8 @@ mod tests { let (key_file, key) = create_test_key()?; let auth = Auth::new(AuthConfig { key: Some(key_file.path().to_string_lossy().to_string()), + jwks_uri: None, + jwks_refresh_interval: None, public: None, })?; @@ -381,6 +497,8 @@ mod tests { let (key_file, key) = create_test_key()?; let auth = Auth::new(AuthConfig { key: Some(key_file.path().to_string_lossy().to_string()), + jwks_uri: None, + jwks_refresh_interval: None, public: None, })?; @@ -410,6 +528,8 @@ mod tests { let (key_file, key) = create_test_key()?; let auth = Auth::new(AuthConfig { key: Some(key_file.path().to_string_lossy().to_string()), + jwks_uri: None, + jwks_refresh_interval: None, public: None, })?; @@ -439,6 +559,8 @@ mod tests { let (key_file, key) = create_test_key()?; let auth = Auth::new(AuthConfig { key: Some(key_file.path().to_string_lossy().to_string()), + jwks_uri: None, + jwks_refresh_interval: None, public: None, })?; @@ -467,6 +589,8 @@ mod tests { let (key_file, key) = create_test_key()?; let auth = Auth::new(AuthConfig { key: Some(key_file.path().to_string_lossy().to_string()), + jwks_uri: None, + jwks_refresh_interval: None, public: None, })?; @@ -505,6 +629,8 @@ mod tests { let (key_file, key) = create_test_key()?; let auth = Auth::new(AuthConfig { key: Some(key_file.path().to_string_lossy().to_string()), + jwks_uri: None, + jwks_refresh_interval: None, public: None, })?; @@ -542,6 +668,8 @@ mod tests { let (key_file, key) = create_test_key()?; let auth = Auth::new(AuthConfig { key: Some(key_file.path().to_string_lossy().to_string()), + jwks_uri: None, + jwks_refresh_interval: None, public: None, })?; diff --git a/rs/moq-relay/src/connection.rs b/rs/moq-relay/src/connection.rs index 3c010e532..f61a672d0 100644 --- a/rs/moq-relay/src/connection.rs +++ b/rs/moq-relay/src/connection.rs @@ -1,4 +1,5 @@ use crate::{Auth, Cluster}; +use std::sync::Arc; use moq_native::Request; @@ -6,7 +7,7 @@ pub struct Connection { pub id: u64, pub request: Request, pub cluster: Cluster, - pub auth: Auth, + pub auth: Arc, } impl Connection { diff --git a/rs/moq-relay/src/main.rs b/rs/moq-relay/src/main.rs index 523410147..bd59d6c54 100644 --- a/rs/moq-relay/src/main.rs +++ b/rs/moq-relay/src/main.rs @@ -8,6 +8,7 @@ pub use auth::*; pub use cluster::*; pub use config::*; pub use connection::*; +use std::sync::Arc; pub use web::*; #[tokio::main] @@ -23,7 +24,7 @@ async fn main() -> anyhow::Result<()> { let addr = config.server.bind.unwrap_or("[::]:443".parse().unwrap()); let mut server = config.server.init()?; let client = config.client.init()?; - let auth = config.auth.init()?; + let auth = Arc::new(config.auth.init()?); let cluster = Cluster::new(config.cluster, client); let cloned = cluster.clone(); diff --git a/rs/moq-relay/src/web.rs b/rs/moq-relay/src/web.rs index 766f11b92..2ae10adfd 100644 --- a/rs/moq-relay/src/web.rs +++ b/rs/moq-relay/src/web.rs @@ -73,7 +73,7 @@ pub struct HttpsConfig { } pub struct WebState { - pub auth: Auth, + pub auth: Arc, pub cluster: Cluster, pub tls_info: Arc>, pub conn_id: AtomicU64, diff --git a/rs/moq-token/Cargo.toml b/rs/moq-token/Cargo.toml index 6e6e79fb2..ee9bbfabe 100644 --- a/rs/moq-token/Cargo.toml +++ b/rs/moq-token/Cargo.toml @@ -20,3 +20,7 @@ rsa = "0.9.9" serde = { version = "1", features = ["derive"] } serde_json = "1" serde_with = { version = "3", features = ["base64"] } +reqwest = { version = "0.13.0-rc.1", optional = true } + +[features] +jwks-loader = ["reqwest"] diff --git a/rs/moq-token/src/lib.rs b/rs/moq-token/src/lib.rs index 8f6f30ef6..ccd59d2bb 100644 --- a/rs/moq-token/src/lib.rs +++ b/rs/moq-token/src/lib.rs @@ -2,7 +2,9 @@ mod algorithm; mod claims; mod generate; mod key; +mod set; pub use algorithm::*; pub use claims::*; pub use key::*; +pub use set::*; diff --git a/rs/moq-token/src/set.rs b/rs/moq-token/src/set.rs new file mode 100644 index 000000000..d9dfc4932 --- /dev/null +++ b/rs/moq-token/src/set.rs @@ -0,0 +1,177 @@ +use crate::{Claims, Key, KeyOperation}; +use anyhow::Context; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use std::path::Path; +use std::sync::{Arc, RwLock}; + +pub trait KeyProvider { + fn get_keys(&self) -> anyhow::Result; + + fn find_key(&self, kid: &str) -> anyhow::Result>> { + Ok(self + .get_keys()? + .keys + .iter() + .find(|k| k.kid.is_some() && k.kid.as_deref().unwrap() == kid) + .cloned()) + } + + fn find_supported_key(&self, operation: &KeyOperation) -> anyhow::Result>> { + Ok(self + .get_keys()? + .keys + .iter() + .find(|key| key.operations.contains(operation)) + .cloned()) + } + + fn decode(&self, token: &str) -> anyhow::Result { + let header = jsonwebtoken::decode_header(token).context("failed to decode JWT header")?; + + let key_set = self.get_keys()?; + let key = match header.kid { + Some(kid) => key_set + .find_key(kid.as_str())? + .ok_or_else(|| anyhow::anyhow!("cannot find key with kid {kid}")), + None => { + if key_set.keys.len() == 1 { + Ok(key_set.keys[0].clone()) + } else { + anyhow::bail!("missing kid in JWT header") + } + } + }?; + + key.decode(token) + } +} + +/// JWK Set to spec https://datatracker.ietf.org/doc/html/rfc7517#section-5 +#[derive(Default, Clone)] +pub struct KeySet { + /// Vec of an arbitrary number of Json Web Keys + pub keys: Vec>, +} + +impl Serialize for KeySet { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + // Serialize as a struct with a `keys` field + use serde::ser::SerializeStruct; + + let mut state = serializer.serialize_struct("KeySet", 1)?; + state.serialize_field("keys", &self.keys.iter().map(|k| k.as_ref()).collect::>())?; + state.end() + } +} + +impl<'de> Deserialize<'de> for KeySet { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + // Deserialize into a temporary Vec + #[derive(Deserialize)] + struct RawKeySet { + keys: Vec, + } + + let raw = RawKeySet::deserialize(deserializer)?; + Ok(KeySet { + keys: raw.keys.into_iter().map(Arc::new).collect(), + }) + } +} + +impl KeySet { + #[allow(clippy::should_implement_trait)] + pub fn from_str(s: &str) -> anyhow::Result { + Ok(serde_json::from_str(s)?) + } + + pub fn from_file>(path: P) -> anyhow::Result { + let json = std::fs::read_to_string(&path)?; + Ok(serde_json::from_str(&json)?) + } + + pub fn to_str(&self) -> anyhow::Result { + Ok(serde_json::to_string(&self)?) + } + + pub fn to_file>(&self, path: P) -> anyhow::Result<()> { + let json = serde_json::to_string(&self)?; + std::fs::write(path, json)?; + Ok(()) + } + + pub fn to_public_set(&self) -> anyhow::Result { + Ok(KeySet { + keys: self + .keys + .iter() + .map(|key| { + key.as_ref() + .to_public() + .map(Arc::new) + .map_err(|e| anyhow::anyhow!("failed to get public key from jwks: {:?}", e)) + }) + .collect::>, _>>()?, + }) + } +} + +impl KeyProvider for KeySet { + fn get_keys(&self) -> anyhow::Result { + Ok(self.clone()) + } +} + +/// JWK Set Loader that allows refreshing of a JWK Set +#[cfg(feature = "jwks-loader")] +pub struct KeySetLoader { + jwks_uri: String, + keys: RwLock>, +} + +#[cfg(feature = "jwks-loader")] +impl KeySetLoader { + pub fn new(jwks_uri: String) -> Self { + Self { + jwks_uri, + keys: RwLock::new(None), // start with no KeySet + } + } + + pub async fn refresh(&self) -> anyhow::Result<()> { + // Fetch the JWKS JSON + let jwks_json = reqwest::get(&self.jwks_uri) + .await + .with_context(|| format!("failed to GET JWKS from {}", self.jwks_uri))? + .error_for_status() + .with_context(|| format!("JWKS endpoint returned error: {}", self.jwks_uri))? + .text() + .await + .context("failed to read JWKS response body")?; + + // Parse the JWKS into a KeySet + let new_keys = KeySet::from_str(&jwks_json).context("Failed to parse JWKS into KeySet")?; + + // Replace the existing KeySet atomically + *self.keys.write().expect("keys write lock poisoned") = Some(new_keys); + + Ok(()) + } +} + +#[cfg(feature = "jwks-loader")] +impl KeyProvider for KeySetLoader { + fn get_keys(&self) -> anyhow::Result { + let guard = self.keys.read().expect("keys read lock poisoned"); + guard + .as_ref() + .cloned() + .ok_or_else(|| anyhow::anyhow!("keys not loaded yet")) + } +} From 60235016ade572df77ec72e7c8546049b8670e78 Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 31 Dec 2025 02:37:14 +0100 Subject: [PATCH 02/15] Cleanup --- rs/moq-relay/src/auth.rs | 22 +++++++--------------- rs/moq-token/Cargo.toml | 8 ++++---- 2 files changed, 11 insertions(+), 19 deletions(-) diff --git a/rs/moq-relay/src/auth.rs b/rs/moq-relay/src/auth.rs index 2c42df6bf..4fccf21ac 100644 --- a/rs/moq-relay/src/auth.rs +++ b/rs/moq-relay/src/auth.rs @@ -90,10 +90,8 @@ impl Drop for Auth { impl Auth { fn compare_key_sets(previous: KeySet, new: KeySet) { for new_key in new.keys { - if new_key.kid.is_some() { - if previous.keys.iter().find(|k| k.kid == new_key.kid).is_none() { - tracing::info!("Found new JWT key \"{}\"", new_key.kid.as_deref().unwrap()) - } + if new_key.kid.is_some() && !previous.keys.iter().any(|k| k.kid == new_key.kid) { + tracing::info!("Found new JWT key \"{}\"", new_key.kid.as_deref().unwrap()) } } } @@ -144,21 +142,15 @@ impl Auth { let key: Option>> = match (config.key.as_deref(), config.jwks_uri.as_deref()) { (Some(key), None) => Some(Box::new(Arc::new(KeySet { - keys: vec![Arc::new(moq_token::Key::from_file(key.to_string())?)], + keys: vec![Arc::new(moq_token::Key::from_file(key)?)], }))), (None, Some(jwks_uri)) => { let loader = Arc::new(KeySetLoader::new(jwks_uri.to_string())); - refresh_task = match config.jwks_refresh_interval { - Some(refresh_interval_secs) => { - // Spawn async task to refresh periodically - Some(Self::spawn_refresh_task( - Duration::from_secs(refresh_interval_secs), - loader.clone(), - )) - } - None => None, - }; + refresh_task = config.jwks_refresh_interval.map(|refresh_interval_secs| { + // Spawn async task to refresh periodically + Self::spawn_refresh_task(Duration::from_secs(refresh_interval_secs), loader.clone()) + }); // TODO Probably the best would be to crash when the initial load fails diff --git a/rs/moq-token/Cargo.toml b/rs/moq-token/Cargo.toml index ee9bbfabe..2dcb44281 100644 --- a/rs/moq-token/Cargo.toml +++ b/rs/moq-token/Cargo.toml @@ -8,6 +8,9 @@ license = "MIT OR Apache-2.0" version = "0.5.5" edition = "2021" +[features] +jwks-loader = ["reqwest"] + [dependencies] anyhow = "1" aws-lc-rs = "1" @@ -16,11 +19,8 @@ elliptic-curve = "0.13.8" jsonwebtoken = { version = "10", features = ["aws_lc_rs"] } p256 = "0.13.2" p384 = "0.13.1" +reqwest = { version = "0.13.0-rc.1", optional = true } rsa = "0.9.9" serde = { version = "1", features = ["derive"] } serde_json = "1" serde_with = { version = "3", features = ["base64"] } -reqwest = { version = "0.13.0-rc.1", optional = true } - -[features] -jwks-loader = ["reqwest"] From f6c8221c15e48866081ff7902f279228d6defd7b Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 31 Dec 2025 02:39:55 +0100 Subject: [PATCH 03/15] Make AuthConfig.key public again --- rs/moq-relay/src/auth.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rs/moq-relay/src/auth.rs b/rs/moq-relay/src/auth.rs index 4fccf21ac..a95c9809d 100644 --- a/rs/moq-relay/src/auth.rs +++ b/rs/moq-relay/src/auth.rs @@ -38,7 +38,7 @@ pub struct AuthConfig { /// The root authentication key. /// If present, all paths will require a token unless they are in the public list. #[arg(long = "auth-key", env = "MOQ_AUTH_KEY", conflicts_with = "jwks_uri")] - key: Option, + pub key: Option, /// The URI to the JWK set. /// If present, all paths will require a token that can be validated with the given JWK set From 25bf57fde2d2f4ef7eddd23c61c5444682d99196 Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 1 Jan 2026 19:34:01 +0100 Subject: [PATCH 04/15] Add relay auth documentation --- doc/guide/authentication.md | 136 ++++++++++++++++++++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 doc/guide/authentication.md diff --git a/doc/guide/authentication.md b/doc/guide/authentication.md new file mode 100644 index 000000000..219e6ebcc --- /dev/null +++ b/doc/guide/authentication.md @@ -0,0 +1,136 @@ +--- +title: moq-authentication +description: Authentication for the moq-relay +--- + +# MoQ authentication + +The MoQ Relay authenticates via JWT-based tokens. Generally there are two different approaches you can choose from: +- asymmetric keys: using a public and private key to separate signing and verifying keys for more security +- symmetric key: using a single secret key for signing and verifying, less secure + +## Symmetric key + +1. Generate a secret key: +```bash +moq-token --key root.jwk generate --algorithm RS256 +``` +:::details You can also choose a different algorithm +- HS256 +- HS384 +- HS512 +::: + +2. Configure relay: +:::code-group +```toml [relay.toml] +[auth] +# public = "anon" # Optional: allow anonymous access to anon/** +key = "root.jwk" # JWT key for authenticated paths +``` +::: + +3. Generate tokens: +```bash +moq-token --key root.jwk sign \ + --root "rooms/123" \ + --publish "alice" \ + --subscribe "" \ + --expires 1735689600 > alice.jwt +``` + +## Asymmetric keys + +Generally asymmetric keys can be more secure because you don't need to distribute the signing key to every relay instance, the relays only need to verifying (public) key. + +1. Generate a public and private key: +```bash +moq-token --key private.jwk generate --public public.jwk --algorithm RS256 +``` +:::details You can also choose a different algorithm +- RS256 +- RS384 +- RS512 +- PS256 +- PS384 +- PS512 +- EC256 +- EC384 +- EdDSA +::: + +2. Now the relay only requires the public key: +:::code-group +```toml [relay.toml] +[auth] +# public = "anon" # Optional: allow anonymous access to anon/** +key = "public.jwk" # JWT key for authenticated paths +``` +::: + +3. Generate tokens using the private key: +```bash +moq-token --key private.jwk sign \ + --root "rooms/123" \ + --publish "alice" \ + --subscribe "" \ + --expires 1735689600 > alice.jwt +``` + +## JWK set authentication + +Instead of storing a public key locally in a file, it may also be retrieved from a server hosting a JWK set. This can be a very simple static site serving a JSON file, or a fully OIDC compliant Identity Provider. That way you can easily implement automatic key rotation. + +::: info +This approach only works with asymmetric authentication. +::: + +To set this up, you need to have an HTTPS server hosting a JWK set that looks like this: +```json +{ + "keys": [ + { + "kid": "2026-01-01", + "alg": "RS256", + "key_ops": [ + "verify" + ], + "kty": "RSA", + "n": "zMsjX1oDV2SMQKZFTx4_qCaD3iIek9s1lvVaymr8bEGzO4pe6syCwBwLmFwaixRv7MMsuZ0nIpoR3Slpo-ZVyRxOc8yc3DcBZx49S_UQcM76E4MYbH6oInrEP8QL2bsstHrYTqTyPPjGwQJVp_sZdkjKlF5N-v5ohpn36sI8PXELvfRY3O3bad-RmSZ8ZOG8CYnJvMj_g2lYtGMMThnddnJ49560ahUNqAbH6ru---sHtdYHcjTIaWX4HYP6Y_KjA6siDZTGTThpaEW45LKcDQWM9sYvx_eAstaC-1rz8Z_6fDgKFWr7qcP5U2NmJ0c-IGSu_8OkftgRH4--Z5mzBQ", + "e": "AQAB" + }, + { + "kid": "2025-12-01", + "alg": "EdDSA", + "key_ops": [ + "verify" + ], + "kty": "OKP", + "crv": "Ed25519", + "x": "2FSK2q_o_d5ernBmNQLNMFxiA4-ypBSa4LsN30ZjUeU" + } + ] +} +``` + +:::tip The following must be considered: +- The endpoint must be HTTPS (unless you know what you're doing, then you can set `dangerously_allow_insecure_jwks = true` to allow HTTP) +- Every JWK MUST be public and contain no private key information +- If your JWK set contains more than one key: + 1. Every JWK MUST have a `kid` so they can be identified on verification + 2. Your JWT tokens MUST contain a `kid` in their header + 3. `kid` can be an arbitrary string +::: + +Configure the relay: +:::code-group +```toml [relay.toml] +[auth] +# public = "anon" # Optional: allow anonymous access to anon/** + +jwks_uri = "https://auth.example.com/keys.json" # JWK set URL for authenticated paths +jwks_refresh_interval = 86400 # Optional: refresh the JWK set every N seconds, no refreshing if omitted +# If you know what you're doing, you can opt in to using a non-secure connection to load the JWK set +# dangerously_allow_insecure_jwks = true +``` +::: From 9d9e711f89cb2cc5f991d8903754592b4a12f645 Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 1 Jan 2026 19:51:26 +0100 Subject: [PATCH 05/15] Add dangerously_allow_insecure_jwks option to AuthConfig --- rs/moq-relay/src/auth.rs | 46 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 42 insertions(+), 4 deletions(-) diff --git a/rs/moq-relay/src/auth.rs b/rs/moq-relay/src/auth.rs index a95c9809d..796005644 100644 --- a/rs/moq-relay/src/auth.rs +++ b/rs/moq-relay/src/auth.rs @@ -46,7 +46,15 @@ pub struct AuthConfig { #[arg(long = "jwks-uri", env = "MOQ_AUTH_JWKS_URI", conflicts_with = "key")] pub jwks_uri: Option, + #[arg( + long = "dangerously-allow-insecure-jwks", + env = "MOQ_AUTH_DANGEROUSLY_ALLOW_INSECURE_JWKS", + conflicts_with = "key" + )] + pub dangerously_allow_insecure_jwks: Option, + /// How often to refresh the JWK set (in seconds), if not provided the JWKs won't be refreshed. + /// Minimum value: 30 #[arg(long = "jwks-refresh-interval", env = "MOQ_AUTH_JWKS_REFRESH_INTERVAL")] pub jwks_refresh_interval: Option, @@ -145,12 +153,26 @@ impl Auth { keys: vec![Arc::new(moq_token::Key::from_file(key)?)], }))), (None, Some(jwks_uri)) => { + if !jwks_uri.starts_with("https") && config.dangerously_allow_insecure_jwks != Some(true) { + tracing::info!("{:?}", config); + anyhow::bail!("jwks_uri must be https") + } + let loader = Arc::new(KeySetLoader::new(jwks_uri.to_string())); - refresh_task = config.jwks_refresh_interval.map(|refresh_interval_secs| { - // Spawn async task to refresh periodically - Self::spawn_refresh_task(Duration::from_secs(refresh_interval_secs), loader.clone()) - }); + refresh_task = config + .jwks_refresh_interval + .map(|refresh_interval_secs| { + if refresh_interval_secs < 30 { + anyhow::bail!("jwks_refresh_interval cannot be less than 30") + } + // Spawn async task to refresh periodically + Ok(Self::spawn_refresh_task( + Duration::from_secs(refresh_interval_secs), + loader.clone(), + )) + }) + .transpose()?; // TODO Probably the best would be to crash when the initial load fails @@ -263,6 +285,7 @@ mod tests { key: None, jwks_uri: None, jwks_refresh_interval: None, + dangerously_allow_insecure_jwks: None, public: Some("anon".to_string()), })?; @@ -288,6 +311,7 @@ mod tests { key: None, jwks_uri: None, jwks_refresh_interval: None, + dangerously_allow_insecure_jwks: None, public: Some("".to_string()), })?; @@ -307,6 +331,7 @@ mod tests { key: None, jwks_uri: None, jwks_refresh_interval: None, + dangerously_allow_insecure_jwks: None, public: Some("anon".to_string()), })?; @@ -324,6 +349,7 @@ mod tests { key: Some(key_file.path().to_string_lossy().to_string()), jwks_uri: None, jwks_refresh_interval: None, + dangerously_allow_insecure_jwks: None, public: None, })?; @@ -340,6 +366,7 @@ mod tests { key: None, jwks_uri: None, jwks_refresh_interval: None, + dangerously_allow_insecure_jwks: None, public: Some("anon".to_string()), })?; @@ -357,6 +384,7 @@ mod tests { key: Some(key_file.path().to_string_lossy().to_string()), jwks_uri: None, jwks_refresh_interval: None, + dangerously_allow_insecure_jwks: None, public: None, })?; @@ -385,6 +413,7 @@ mod tests { key: Some(key_file.path().to_string_lossy().to_string()), jwks_uri: None, jwks_refresh_interval: None, + dangerously_allow_insecure_jwks: None, public: None, })?; @@ -411,6 +440,7 @@ mod tests { key: Some(key_file.path().to_string_lossy().to_string()), jwks_uri: None, jwks_refresh_interval: None, + dangerously_allow_insecure_jwks: None, public: None, })?; @@ -439,6 +469,7 @@ mod tests { key: Some(key_file.path().to_string_lossy().to_string()), jwks_uri: None, jwks_refresh_interval: None, + dangerously_allow_insecure_jwks: None, public: None, })?; @@ -465,6 +496,7 @@ mod tests { key: Some(key_file.path().to_string_lossy().to_string()), jwks_uri: None, jwks_refresh_interval: None, + dangerously_allow_insecure_jwks: None, public: None, })?; @@ -491,6 +523,7 @@ mod tests { key: Some(key_file.path().to_string_lossy().to_string()), jwks_uri: None, jwks_refresh_interval: None, + dangerously_allow_insecure_jwks: None, public: None, })?; @@ -522,6 +555,7 @@ mod tests { key: Some(key_file.path().to_string_lossy().to_string()), jwks_uri: None, jwks_refresh_interval: None, + dangerously_allow_insecure_jwks: None, public: None, })?; @@ -553,6 +587,7 @@ mod tests { key: Some(key_file.path().to_string_lossy().to_string()), jwks_uri: None, jwks_refresh_interval: None, + dangerously_allow_insecure_jwks: None, public: None, })?; @@ -583,6 +618,7 @@ mod tests { key: Some(key_file.path().to_string_lossy().to_string()), jwks_uri: None, jwks_refresh_interval: None, + dangerously_allow_insecure_jwks: None, public: None, })?; @@ -623,6 +659,7 @@ mod tests { key: Some(key_file.path().to_string_lossy().to_string()), jwks_uri: None, jwks_refresh_interval: None, + dangerously_allow_insecure_jwks: None, public: None, })?; @@ -662,6 +699,7 @@ mod tests { key: Some(key_file.path().to_string_lossy().to_string()), jwks_uri: None, jwks_refresh_interval: None, + dangerously_allow_insecure_jwks: None, public: None, })?; From 7db12547e0cfb273883846cb2a71e6c554ab978f Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 1 Jan 2026 20:18:06 +0100 Subject: [PATCH 06/15] Use default for AuthConfig initialization --- rs/moq-relay/src/auth.rs | 80 ++++++++-------------------------------- 1 file changed, 16 insertions(+), 64 deletions(-) diff --git a/rs/moq-relay/src/auth.rs b/rs/moq-relay/src/auth.rs index 796005644..34dcca6d2 100644 --- a/rs/moq-relay/src/auth.rs +++ b/rs/moq-relay/src/auth.rs @@ -282,11 +282,8 @@ mod tests { fn test_anonymous_access_with_public_path() -> anyhow::Result<()> { // Test anonymous access to /anon path let auth = Auth::new(AuthConfig { - key: None, - jwks_uri: None, - jwks_refresh_interval: None, - dangerously_allow_insecure_jwks: None, public: Some("anon".to_string()), + ..Default::default() })?; // Should succeed for anonymous path @@ -308,11 +305,8 @@ mod tests { fn test_anonymous_access_fully_public() -> anyhow::Result<()> { // Test fully public access (public = "") let auth = Auth::new(AuthConfig { - key: None, - jwks_uri: None, - jwks_refresh_interval: None, - dangerously_allow_insecure_jwks: None, public: Some("".to_string()), + ..Default::default() })?; // Should succeed for any path @@ -328,11 +322,8 @@ mod tests { fn test_anonymous_access_denied_wrong_prefix() -> anyhow::Result<()> { // Test anonymous access denied for wrong prefix let auth = Auth::new(AuthConfig { - key: None, - jwks_uri: None, - jwks_refresh_interval: None, - dangerously_allow_insecure_jwks: None, public: Some("anon".to_string()), + ..Default::default() })?; // Should fail for non-anonymous path @@ -347,10 +338,7 @@ mod tests { let (key_file, _) = create_test_key()?; let auth = Auth::new(AuthConfig { key: Some(key_file.path().to_string_lossy().to_string()), - jwks_uri: None, - jwks_refresh_interval: None, - dangerously_allow_insecure_jwks: None, - public: None, + ..Default::default() })?; // Should fail when no token and no public path @@ -363,11 +351,8 @@ mod tests { #[test] fn test_token_provided_but_no_key_configured() -> anyhow::Result<()> { let auth = Auth::new(AuthConfig { - key: None, - jwks_uri: None, - jwks_refresh_interval: None, - dangerously_allow_insecure_jwks: None, public: Some("anon".to_string()), + ..Default::default() })?; // Should fail when token provided but no key configured @@ -382,10 +367,7 @@ mod tests { let (key_file, key) = create_test_key()?; let auth = Auth::new(AuthConfig { key: Some(key_file.path().to_string_lossy().to_string()), - jwks_uri: None, - jwks_refresh_interval: None, - dangerously_allow_insecure_jwks: None, - public: None, + ..Default::default() })?; // Create a token with basic permissions @@ -411,10 +393,7 @@ mod tests { let (key_file, key) = create_test_key()?; let auth = Auth::new(AuthConfig { key: Some(key_file.path().to_string_lossy().to_string()), - jwks_uri: None, - jwks_refresh_interval: None, - dangerously_allow_insecure_jwks: None, - public: None, + ..Default::default() })?; // Create a token for room/123 @@ -438,10 +417,7 @@ mod tests { let (key_file, key) = create_test_key()?; let auth = Auth::new(AuthConfig { key: Some(key_file.path().to_string_lossy().to_string()), - jwks_uri: None, - jwks_refresh_interval: None, - dangerously_allow_insecure_jwks: None, - public: None, + ..Default::default() })?; // Create a token with specific pub/sub restrictions @@ -467,10 +443,7 @@ mod tests { let (key_file, key) = create_test_key()?; let auth = Auth::new(AuthConfig { key: Some(key_file.path().to_string_lossy().to_string()), - jwks_uri: None, - jwks_refresh_interval: None, - dangerously_allow_insecure_jwks: None, - public: None, + ..Default::default() })?; // Create a read-only token (no publish permissions) @@ -494,10 +467,7 @@ mod tests { let (key_file, key) = create_test_key()?; let auth = Auth::new(AuthConfig { key: Some(key_file.path().to_string_lossy().to_string()), - jwks_uri: None, - jwks_refresh_interval: None, - dangerously_allow_insecure_jwks: None, - public: None, + ..Default::default() })?; // Create a write-only token (no subscribe permissions) @@ -521,10 +491,7 @@ mod tests { let (key_file, key) = create_test_key()?; let auth = Auth::new(AuthConfig { key: Some(key_file.path().to_string_lossy().to_string()), - jwks_uri: None, - jwks_refresh_interval: None, - dangerously_allow_insecure_jwks: None, - public: None, + ..Default::default() })?; // Create a token with root at room/123 and unrestricted pub/sub @@ -553,10 +520,7 @@ mod tests { let (key_file, key) = create_test_key()?; let auth = Auth::new(AuthConfig { key: Some(key_file.path().to_string_lossy().to_string()), - jwks_uri: None, - jwks_refresh_interval: None, - dangerously_allow_insecure_jwks: None, - public: None, + ..Default::default() })?; // Token allows publishing only to alice/* @@ -585,10 +549,7 @@ mod tests { let (key_file, key) = create_test_key()?; let auth = Auth::new(AuthConfig { key: Some(key_file.path().to_string_lossy().to_string()), - jwks_uri: None, - jwks_refresh_interval: None, - dangerously_allow_insecure_jwks: None, - public: None, + ..Default::default() })?; // Token allows subscribing only to bob/* @@ -616,10 +577,7 @@ mod tests { let (key_file, key) = create_test_key()?; let auth = Auth::new(AuthConfig { key: Some(key_file.path().to_string_lossy().to_string()), - jwks_uri: None, - jwks_refresh_interval: None, - dangerously_allow_insecure_jwks: None, - public: None, + ..Default::default() })?; // Token allows publishing to alice/* and subscribing to bob/* @@ -657,10 +615,7 @@ mod tests { let (key_file, key) = create_test_key()?; let auth = Auth::new(AuthConfig { key: Some(key_file.path().to_string_lossy().to_string()), - jwks_uri: None, - jwks_refresh_interval: None, - dangerously_allow_insecure_jwks: None, - public: None, + ..Default::default() })?; // Token with nested publish/subscribe paths @@ -697,10 +652,7 @@ mod tests { let (key_file, key) = create_test_key()?; let auth = Auth::new(AuthConfig { key: Some(key_file.path().to_string_lossy().to_string()), - jwks_uri: None, - jwks_refresh_interval: None, - dangerously_allow_insecure_jwks: None, - public: None, + ..Default::default() })?; // Read-only token From 483e715cdb82411c0120690f106b671fed7e646b Mon Sep 17 00:00:00 2001 From: Robin Date: Sat, 3 Jan 2026 16:29:23 +0100 Subject: [PATCH 07/15] Remove dangerously_allow_insecure_jwks option --- doc/guide/authentication.md | 3 --- rs/moq-relay/src/auth.rs | 13 +------------ 2 files changed, 1 insertion(+), 15 deletions(-) diff --git a/doc/guide/authentication.md b/doc/guide/authentication.md index 219e6ebcc..1881e71ed 100644 --- a/doc/guide/authentication.md +++ b/doc/guide/authentication.md @@ -114,7 +114,6 @@ To set this up, you need to have an HTTPS server hosting a JWK set that looks li ``` :::tip The following must be considered: -- The endpoint must be HTTPS (unless you know what you're doing, then you can set `dangerously_allow_insecure_jwks = true` to allow HTTP) - Every JWK MUST be public and contain no private key information - If your JWK set contains more than one key: 1. Every JWK MUST have a `kid` so they can be identified on verification @@ -130,7 +129,5 @@ Configure the relay: jwks_uri = "https://auth.example.com/keys.json" # JWK set URL for authenticated paths jwks_refresh_interval = 86400 # Optional: refresh the JWK set every N seconds, no refreshing if omitted -# If you know what you're doing, you can opt in to using a non-secure connection to load the JWK set -# dangerously_allow_insecure_jwks = true ``` ::: diff --git a/rs/moq-relay/src/auth.rs b/rs/moq-relay/src/auth.rs index 34dcca6d2..ea576590f 100644 --- a/rs/moq-relay/src/auth.rs +++ b/rs/moq-relay/src/auth.rs @@ -46,14 +46,8 @@ pub struct AuthConfig { #[arg(long = "jwks-uri", env = "MOQ_AUTH_JWKS_URI", conflicts_with = "key")] pub jwks_uri: Option, - #[arg( - long = "dangerously-allow-insecure-jwks", - env = "MOQ_AUTH_DANGEROUSLY_ALLOW_INSECURE_JWKS", - conflicts_with = "key" - )] - pub dangerously_allow_insecure_jwks: Option, - /// How often to refresh the JWK set (in seconds), if not provided the JWKs won't be refreshed. + /// If not provided, there won't be any refreshing, the JWK set will only be loaded once at startup. /// Minimum value: 30 #[arg(long = "jwks-refresh-interval", env = "MOQ_AUTH_JWKS_REFRESH_INTERVAL")] pub jwks_refresh_interval: Option, @@ -153,11 +147,6 @@ impl Auth { keys: vec![Arc::new(moq_token::Key::from_file(key)?)], }))), (None, Some(jwks_uri)) => { - if !jwks_uri.starts_with("https") && config.dangerously_allow_insecure_jwks != Some(true) { - tracing::info!("{:?}", config); - anyhow::bail!("jwks_uri must be https") - } - let loader = Arc::new(KeySetLoader::new(jwks_uri.to_string())); refresh_task = config From b7119eaa6fdd670113fe044c0885699fb7f3cb93 Mon Sep 17 00:00:00 2001 From: Robin Date: Sat, 3 Jan 2026 16:59:52 +0100 Subject: [PATCH 08/15] Combine AuthConfig key and jwks_uri into one option --- doc/guide/authentication.md | 2 +- rs/moq-relay/src/auth.rs | 74 ++++++++++++++++++------------------- 2 files changed, 36 insertions(+), 40 deletions(-) diff --git a/doc/guide/authentication.md b/doc/guide/authentication.md index 1881e71ed..ef49d89c7 100644 --- a/doc/guide/authentication.md +++ b/doc/guide/authentication.md @@ -127,7 +127,7 @@ Configure the relay: [auth] # public = "anon" # Optional: allow anonymous access to anon/** -jwks_uri = "https://auth.example.com/keys.json" # JWK set URL for authenticated paths +key = "https://auth.example.com/keys.json" # JWK set URL for authenticated paths jwks_refresh_interval = 86400 # Optional: refresh the JWK set every N seconds, no refreshing if omitted ``` ::: diff --git a/rs/moq-relay/src/auth.rs b/rs/moq-relay/src/auth.rs index ea576590f..b2352eff0 100644 --- a/rs/moq-relay/src/auth.rs +++ b/rs/moq-relay/src/auth.rs @@ -1,3 +1,4 @@ +use anyhow::Context; use axum::http; use moq_lite::{AsPath, Path, PathOwned}; use moq_token::{KeyProvider, KeySet, KeySetLoader}; @@ -35,20 +36,14 @@ impl axum::response::IntoResponse for AuthError { #[derive(clap::Args, Clone, Debug, Serialize, Deserialize, Default)] #[serde(default)] pub struct AuthConfig { - /// The root authentication key. + /// Either the root authentication key or a URI to a JWK set. /// If present, all paths will require a token unless they are in the public list. - #[arg(long = "auth-key", env = "MOQ_AUTH_KEY", conflicts_with = "jwks_uri")] + #[arg(long = "auth-key", env = "MOQ_AUTH_KEY")] pub key: Option, - /// The URI to the JWK set. - /// If present, all paths will require a token that can be validated with the given JWK set - /// unless they are in the public list. - #[arg(long = "jwks-uri", env = "MOQ_AUTH_JWKS_URI", conflicts_with = "key")] - pub jwks_uri: Option, - - /// How often to refresh the JWK set (in seconds), if not provided the JWKs won't be refreshed. + /// How often to refresh the JWK set (in seconds), will be ignored if the `key` is not a valid URI. /// If not provided, there won't be any refreshing, the JWK set will only be loaded once at startup. - /// Minimum value: 30 + /// Minimum value: 30, defaults to None #[arg(long = "jwks-refresh-interval", env = "MOQ_AUTH_JWKS_REFRESH_INTERVAL")] pub jwks_refresh_interval: Option, @@ -141,35 +136,36 @@ impl Auth { pub fn new(config: AuthConfig) -> anyhow::Result { let mut refresh_task = None; - let key: Option>> = - match (config.key.as_deref(), config.jwks_uri.as_deref()) { - (Some(key), None) => Some(Box::new(Arc::new(KeySet { - keys: vec![Arc::new(moq_token::Key::from_file(key)?)], - }))), - (None, Some(jwks_uri)) => { - let loader = Arc::new(KeySetLoader::new(jwks_uri.to_string())); - - refresh_task = config - .jwks_refresh_interval - .map(|refresh_interval_secs| { - if refresh_interval_secs < 30 { - anyhow::bail!("jwks_refresh_interval cannot be less than 30") - } - // Spawn async task to refresh periodically - Ok(Self::spawn_refresh_task( - Duration::from_secs(refresh_interval_secs), - loader.clone(), - )) - }) - .transpose()?; - - // TODO Probably the best would be to crash when the initial load fails - - Some(Box::new(loader)) + let key: Option> = match config.key { + Some(uri) if uri.starts_with("http://") || uri.starts_with("https://") => { + let loader = Arc::new(KeySetLoader::new(uri)); + + if let Some(refresh_interval_secs) = config.jwks_refresh_interval { + anyhow::ensure!( + refresh_interval_secs >= 30, + "jwks_refresh_interval cannot be less than 30" + ); + + refresh_task = Some(Self::spawn_refresh_task( + Duration::from_secs(refresh_interval_secs), + loader.clone(), + )); } - (Some(_), Some(_)) => anyhow::bail!("Cannot provide both key and jwks_uri, choose one!"), - (None, None) => None, - }; + + // TODO Probably the best would be to crash when the initial load fails + Some(loader) + } + + Some(key_file) => { + let key = moq_token::Key::from_file(&key_file) + .with_context(|| format!("cannot load key from {}", &key_file))?; + Some(Arc::new(KeySet { + keys: vec![Arc::new(key)], + })) + } + + None => None, + }; let public = config.public; @@ -180,7 +176,7 @@ impl Auth { } Ok(Self { - key, + key: key.map(Box::new), public: public.map(|p| p.as_path().to_owned()), refresh_task, }) From 7882d761d16712626170a071b244424650f1e6d0 Mon Sep 17 00:00:00 2001 From: Robin Date: Sat, 3 Jan 2026 18:30:41 +0100 Subject: [PATCH 09/15] Improve KeySet implementation --- rs/moq-relay/src/auth.rs | 93 ++++++++++++++++------------ rs/moq-token/src/set.rs | 128 +++++++++++++-------------------------- 2 files changed, 98 insertions(+), 123 deletions(-) diff --git a/rs/moq-relay/src/auth.rs b/rs/moq-relay/src/auth.rs index b2352eff0..cfcae6d42 100644 --- a/rs/moq-relay/src/auth.rs +++ b/rs/moq-relay/src/auth.rs @@ -1,9 +1,9 @@ use anyhow::Context; use axum::http; use moq_lite::{AsPath, Path, PathOwned}; -use moq_token::{KeyProvider, KeySet, KeySetLoader}; +use moq_token::KeySet; use serde::{Deserialize, Serialize}; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use std::time::Duration; #[derive(thiserror::Error, Debug, Clone)] @@ -71,7 +71,7 @@ pub struct AuthToken { const JWKS_REFRESH_ERROR_INTERVAL: Duration = Duration::from_mins(5); pub struct Auth { - key: Option>>, + key: Option>>, public: Option, refresh_task: Option>, } @@ -85,30 +85,32 @@ impl Drop for Auth { } impl Auth { - fn compare_key_sets(previous: KeySet, new: KeySet) { - for new_key in new.keys { + fn compare_key_sets(previous: &KeySet, new: &KeySet) { + for new_key in new.keys.iter() { if new_key.kid.is_some() && !previous.keys.iter().any(|k| k.kid == new_key.kid) { - tracing::info!("Found new JWT key \"{}\"", new_key.kid.as_deref().unwrap()) + tracing::info!("Found new JWK \"{}\"", new_key.kid.as_deref().unwrap()) } } } - async fn refresh(loader: &KeySetLoader) -> anyhow::Result<()> { - let previous = loader.get_keys(); + async fn refresh_key_set(jwks_uri: &str, key_set: &Mutex) -> anyhow::Result<()> { + let new_keys = moq_token::load_keys(jwks_uri).await?; - let result = loader.refresh().await; - if let Ok(()) = result { - if let (Ok(previous), Ok(new)) = (previous, loader.get_keys()) { - Self::compare_key_sets(previous, new); - } - } - result + let mut key_set = key_set.lock().expect("keyset mutex poisoned"); + Self::compare_key_sets(&key_set, &new_keys); + *key_set = new_keys; + + Ok(()) } - fn spawn_refresh_task(interval: Duration, loader: Arc) -> tokio::task::JoinHandle<()> { + fn spawn_refresh_task( + interval: Duration, + key_set: Arc>, + jwks_uri: String, + ) -> tokio::task::JoinHandle<()> { tokio::spawn(async move { loop { - if let Err(e) = Self::refresh(loader.as_ref()).await { + if let Err(e) = Self::refresh_key_set(&jwks_uri, key_set.as_ref()).await { if interval > JWKS_REFRESH_ERROR_INTERVAL * 2 { tracing::error!( "failed to load JWKS, will retry in {} seconds: {:?}", @@ -117,7 +119,7 @@ impl Auth { ); tokio::time::sleep(JWKS_REFRESH_ERROR_INTERVAL).await; - if let Err(e) = Self::refresh(loader.as_ref()).await { + if let Err(e) = Self::refresh_key_set(&jwks_uri, key_set.as_ref()).await { tracing::error!("failed to load JWKS again, giving up this time: {:?}", e); } else { tracing::info!("successfully loaded JWKS on the second try"); @@ -136,32 +138,44 @@ impl Auth { pub fn new(config: AuthConfig) -> anyhow::Result { let mut refresh_task = None; - let key: Option> = match config.key { + let key = match config.key { Some(uri) if uri.starts_with("http://") || uri.starts_with("https://") => { - let loader = Arc::new(KeySetLoader::new(uri)); - - if let Some(refresh_interval_secs) = config.jwks_refresh_interval { - anyhow::ensure!( - refresh_interval_secs >= 30, - "jwks_refresh_interval cannot be less than 30" - ); - - refresh_task = Some(Self::spawn_refresh_task( - Duration::from_secs(refresh_interval_secs), - loader.clone(), - )); + // Start with an empty KeySet + let key_set = Arc::new(Mutex::new(KeySet::default())); + + // TODO Better error handling when initial load fails + match config.jwks_refresh_interval { + Some(refresh_interval_secs) => { + anyhow::ensure!( + refresh_interval_secs >= 30, + "jwks_refresh_interval cannot be less than 30" + ); + + refresh_task = Some(Self::spawn_refresh_task( + Duration::from_secs(refresh_interval_secs), + key_set.clone(), + uri, + )); + } + None => { + let key_set = key_set.clone(); + tokio::spawn(async move { + Self::refresh_key_set(&uri, key_set.as_ref()) + .await + .expect("failed to load key set"); + }); + } } - // TODO Probably the best would be to crash when the initial load fails - Some(loader) + Some(key_set) } Some(key_file) => { let key = moq_token::Key::from_file(&key_file) .with_context(|| format!("cannot load key from {}", &key_file))?; - Some(Arc::new(KeySet { + Some(Arc::new(Mutex::new(KeySet { keys: vec![Arc::new(key)], - })) + }))) } None => None, @@ -176,7 +190,7 @@ impl Auth { } Ok(Self { - key: key.map(Box::new), + key, public: public.map(|p| p.as_path().to_owned()), refresh_task, }) @@ -188,8 +202,11 @@ impl Auth { // Find the token in the query parameters. // ?jwt=... let claims = if let Some(token) = token { - if let Some(key) = self.key.as_ref() { - key.decode(token).map_err(|_| AuthError::DecodeFailed)? + if let Some(key) = self.key.as_deref() { + key.lock() + .expect("key mutex poisoned") + .decode(token) + .map_err(|_| AuthError::DecodeFailed)? } else { return Err(AuthError::UnexpectedToken); } diff --git a/rs/moq-token/src/set.rs b/rs/moq-token/src/set.rs index d9dfc4932..c6351ec68 100644 --- a/rs/moq-token/src/set.rs +++ b/rs/moq-token/src/set.rs @@ -2,49 +2,7 @@ use crate::{Claims, Key, KeyOperation}; use anyhow::Context; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use std::path::Path; -use std::sync::{Arc, RwLock}; - -pub trait KeyProvider { - fn get_keys(&self) -> anyhow::Result; - - fn find_key(&self, kid: &str) -> anyhow::Result>> { - Ok(self - .get_keys()? - .keys - .iter() - .find(|k| k.kid.is_some() && k.kid.as_deref().unwrap() == kid) - .cloned()) - } - - fn find_supported_key(&self, operation: &KeyOperation) -> anyhow::Result>> { - Ok(self - .get_keys()? - .keys - .iter() - .find(|key| key.operations.contains(operation)) - .cloned()) - } - - fn decode(&self, token: &str) -> anyhow::Result { - let header = jsonwebtoken::decode_header(token).context("failed to decode JWT header")?; - - let key_set = self.get_keys()?; - let key = match header.kid { - Some(kid) => key_set - .find_key(kid.as_str())? - .ok_or_else(|| anyhow::anyhow!("cannot find key with kid {kid}")), - None => { - if key_set.keys.len() == 1 { - Ok(key_set.keys[0].clone()) - } else { - anyhow::bail!("missing kid in JWT header") - } - } - }?; - - key.decode(token) - } -} +use std::sync::Arc; /// JWK Set to spec https://datatracker.ietf.org/doc/html/rfc7517#section-5 #[derive(Default, Clone)] @@ -120,58 +78,58 @@ impl KeySet { .collect::>, _>>()?, }) } -} -impl KeyProvider for KeySet { - fn get_keys(&self) -> anyhow::Result { - Ok(self.clone()) + pub fn find_key(&self, kid: &str) -> Option> { + self.keys + .iter() + .find(|k| k.kid.is_some() && k.kid.as_deref().unwrap() == kid) + .cloned() } -} - -/// JWK Set Loader that allows refreshing of a JWK Set -#[cfg(feature = "jwks-loader")] -pub struct KeySetLoader { - jwks_uri: String, - keys: RwLock>, -} -#[cfg(feature = "jwks-loader")] -impl KeySetLoader { - pub fn new(jwks_uri: String) -> Self { - Self { - jwks_uri, - keys: RwLock::new(None), // start with no KeySet - } + pub fn find_supported_key(&self, operation: &KeyOperation) -> Option> { + self.keys.iter().find(|key| key.operations.contains(operation)).cloned() } - pub async fn refresh(&self) -> anyhow::Result<()> { - // Fetch the JWKS JSON - let jwks_json = reqwest::get(&self.jwks_uri) - .await - .with_context(|| format!("failed to GET JWKS from {}", self.jwks_uri))? - .error_for_status() - .with_context(|| format!("JWKS endpoint returned error: {}", self.jwks_uri))? - .text() - .await - .context("failed to read JWKS response body")?; + pub fn encode(&self, payload: &Claims) -> anyhow::Result { + let key = self + .find_supported_key(&KeyOperation::Sign) + .context("cannot find signing key")?; + key.encode(payload) + } - // Parse the JWKS into a KeySet - let new_keys = KeySet::from_str(&jwks_json).context("Failed to parse JWKS into KeySet")?; + pub fn decode(&self, token: &str) -> anyhow::Result { + let header = jsonwebtoken::decode_header(token).context("failed to decode JWT header")?; - // Replace the existing KeySet atomically - *self.keys.write().expect("keys write lock poisoned") = Some(new_keys); + let key = match header.kid { + Some(kid) => self + .find_key(kid.as_str()) + .ok_or_else(|| anyhow::anyhow!("cannot find key with kid {kid}")), + None => { + // If we only have one key we can use it without a kid + if self.keys.len() == 1 { + Ok(self.keys[0].clone()) + } else { + anyhow::bail!("missing kid in JWT header") + } + } + }?; - Ok(()) + key.decode(token) } } #[cfg(feature = "jwks-loader")] -impl KeyProvider for KeySetLoader { - fn get_keys(&self) -> anyhow::Result { - let guard = self.keys.read().expect("keys read lock poisoned"); - guard - .as_ref() - .cloned() - .ok_or_else(|| anyhow::anyhow!("keys not loaded yet")) - } +pub async fn load_keys(jwks_uri: &str) -> anyhow::Result { + // Fetch the JWKS JSON + let jwks_json = reqwest::get(jwks_uri) + .await + .with_context(|| format!("failed to GET JWKS from {}", jwks_uri))? + .error_for_status() + .with_context(|| format!("JWKS endpoint returned error: {}", jwks_uri))? + .text() + .await + .context("failed to read JWKS response body")?; + + // Parse the JWKS into a KeySet + KeySet::from_str(&jwks_json).context("Failed to parse JWKS into KeySet") } From 91bf98f53f29b2cdf66388257571d560cb74f4bd Mon Sep 17 00:00:00 2001 From: Robin Date: Sat, 3 Jan 2026 19:44:42 +0100 Subject: [PATCH 10/15] Make Auth struct cloneable --- rs/moq-relay/src/auth.rs | 7 ++++--- rs/moq-relay/src/connection.rs | 3 +-- rs/moq-relay/src/main.rs | 3 +-- rs/moq-relay/src/web.rs | 2 +- 4 files changed, 7 insertions(+), 8 deletions(-) diff --git a/rs/moq-relay/src/auth.rs b/rs/moq-relay/src/auth.rs index cfcae6d42..2720106df 100644 --- a/rs/moq-relay/src/auth.rs +++ b/rs/moq-relay/src/auth.rs @@ -70,15 +70,16 @@ pub struct AuthToken { const JWKS_REFRESH_ERROR_INTERVAL: Duration = Duration::from_mins(5); +#[derive(Clone)] pub struct Auth { key: Option>>, public: Option, - refresh_task: Option>, + refresh_task: Option>>, } impl Drop for Auth { fn drop(&mut self) { - if let Some(handle) = &self.refresh_task { + if let Some(handle) = self.refresh_task.as_deref() { handle.abort(); } } @@ -192,7 +193,7 @@ impl Auth { Ok(Self { key, public: public.map(|p| p.as_path().to_owned()), - refresh_task, + refresh_task: refresh_task.map(Arc::new), }) } diff --git a/rs/moq-relay/src/connection.rs b/rs/moq-relay/src/connection.rs index f61a672d0..3c010e532 100644 --- a/rs/moq-relay/src/connection.rs +++ b/rs/moq-relay/src/connection.rs @@ -1,5 +1,4 @@ use crate::{Auth, Cluster}; -use std::sync::Arc; use moq_native::Request; @@ -7,7 +6,7 @@ pub struct Connection { pub id: u64, pub request: Request, pub cluster: Cluster, - pub auth: Arc, + pub auth: Auth, } impl Connection { diff --git a/rs/moq-relay/src/main.rs b/rs/moq-relay/src/main.rs index bd59d6c54..523410147 100644 --- a/rs/moq-relay/src/main.rs +++ b/rs/moq-relay/src/main.rs @@ -8,7 +8,6 @@ pub use auth::*; pub use cluster::*; pub use config::*; pub use connection::*; -use std::sync::Arc; pub use web::*; #[tokio::main] @@ -24,7 +23,7 @@ async fn main() -> anyhow::Result<()> { let addr = config.server.bind.unwrap_or("[::]:443".parse().unwrap()); let mut server = config.server.init()?; let client = config.client.init()?; - let auth = Arc::new(config.auth.init()?); + let auth = config.auth.init()?; let cluster = Cluster::new(config.cluster, client); let cloned = cluster.clone(); diff --git a/rs/moq-relay/src/web.rs b/rs/moq-relay/src/web.rs index 2ae10adfd..766f11b92 100644 --- a/rs/moq-relay/src/web.rs +++ b/rs/moq-relay/src/web.rs @@ -73,7 +73,7 @@ pub struct HttpsConfig { } pub struct WebState { - pub auth: Arc, + pub auth: Auth, pub cluster: Cluster, pub tls_info: Arc>, pub conn_id: AtomicU64, From cbf2dd2ee454e6398e05ab15f21e46f81bca979d Mon Sep 17 00:00:00 2001 From: Robin Date: Sat, 17 Jan 2026 13:55:53 +0100 Subject: [PATCH 11/15] Add JWK set unit tests --- rs/moq-token/src/set.rs | 282 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 282 insertions(+) diff --git a/rs/moq-token/src/set.rs b/rs/moq-token/src/set.rs index c6351ec68..5e6c45991 100644 --- a/rs/moq-token/src/set.rs +++ b/rs/moq-token/src/set.rs @@ -133,3 +133,285 @@ pub async fn load_keys(jwks_uri: &str) -> anyhow::Result { // Parse the JWKS into a KeySet KeySet::from_str(&jwks_json).context("Failed to parse JWKS into KeySet") } + +#[cfg(test)] +mod tests { + use super::*; + use crate::Algorithm; + use std::time::{Duration, SystemTime}; + + fn create_test_claims() -> Claims { + Claims { + root: "test-path".to_string(), + publish: vec!["test-pub".into()], + cluster: false, + subscribe: vec!["test-sub".into()], + expires: Some(SystemTime::now() + Duration::from_secs(3600)), + issued: Some(SystemTime::now()), + } + } + + fn create_test_key(kid: Option) -> Key { + Key::generate(Algorithm::ES256, kid).expect("failed to generate key") + } + + #[test] + fn test_keyset_from_str_valid() { + let json = r#"{"keys":[{"kty":"oct","k":"2AJvfDJMVfWe9WMRPJP-4zCGN8F62LOy3dUr--rogR8","alg":"HS256","key_ops":["verify","sign"],"kid":"1"}]}"#; + let set = KeySet::from_str(json); + assert!(set.is_ok()); + let set = set.unwrap(); + assert_eq!(set.keys.len(), 1); + assert_eq!(set.keys[0].kid.as_deref(), Some("1")); + assert!(set.find_key("1").is_some()); + } + + #[test] + fn test_keyset_from_str_invalid_json() { + let result = KeySet::from_str("invalid json"); + assert!(result.is_err()); + } + + #[test] + fn test_keyset_from_str_empty() { + let json = r#"{"keys":[]}"#; + let set = KeySet::from_str(json).unwrap(); + assert!(set.keys.is_empty()); + } + + #[test] + fn test_keyset_to_str() { + let key = create_test_key(Some("1".to_string())); + let set = KeySet { + keys: vec![Arc::new(key)], + }; + + let json = set.to_str().unwrap(); + assert!(json.contains("\"keys\"")); + assert!(json.contains("\"kid\":\"1\"")); + } + + #[test] + fn test_keyset_serde_round_trip() { + let key1 = create_test_key(Some("1".to_string())); + let key2 = create_test_key(Some("2".to_string())); + let set = KeySet { + keys: vec![Arc::new(key1), Arc::new(key2)], + }; + + let json = set.to_str().unwrap(); + let deserialized = KeySet::from_str(&json).unwrap(); + + assert_eq!(deserialized.keys.len(), 2); + assert!(deserialized.find_key("1").is_some()); + assert!(deserialized.find_key("2").is_some()); + } + + #[test] + fn test_find_key_success() { + let key = create_test_key(Some("my-key".to_string())); + let set = KeySet { + keys: vec![Arc::new(key)], + }; + + let found = set.find_key("my-key"); + assert!(found.is_some()); + assert_eq!(found.unwrap().kid.as_deref(), Some("my-key")); + } + + #[test] + fn test_find_key_missing() { + let key = create_test_key(Some("my-key".to_string())); + let set = KeySet { + keys: vec![Arc::new(key)], + }; + + let found = set.find_key("other-key"); + assert!(found.is_none()); + } + + #[test] + fn test_find_key_no_kid() { + let key = create_test_key(None); + let set = KeySet { + keys: vec![Arc::new(key)], + }; + + let found = set.find_key("any-key"); + assert!(found.is_none()); + } + + #[test] + fn test_find_supported_key() { + let mut sign_key = create_test_key(Some("sign".to_string())); + sign_key.operations = [KeyOperation::Sign].into(); + + let mut verify_key = create_test_key(Some("verify".to_string())); + verify_key.operations = [KeyOperation::Verify].into(); + + let set = KeySet { + keys: vec![Arc::new(sign_key), Arc::new(verify_key)], + }; + + let found_sign = set.find_supported_key(&KeyOperation::Sign); + assert!(found_sign.is_some()); + assert_eq!(found_sign.unwrap().kid.as_deref(), Some("sign")); + + let found_verify = set.find_supported_key(&KeyOperation::Verify); + assert!(found_verify.is_some()); + assert_eq!(found_verify.unwrap().kid.as_deref(), Some("verify")); + } + + #[test] + fn test_to_public_set() { + // Use asymmetric key (ES256) so we can separate public/private + let key = create_test_key(Some("1".to_string())); + + let set = KeySet { + keys: vec![Arc::new(key)], + }; + + let public_set = set.to_public_set().expect("failed to convert to public set"); + assert_eq!(public_set.keys.len(), 1); + + let public_key = &public_set.keys[0]; + assert_eq!(public_key.kid.as_deref(), Some("1")); + assert!(public_key.operations.contains(&KeyOperation::Verify)); + assert!(!public_key.operations.contains(&KeyOperation::Sign)); + } + + #[test] + fn test_to_public_set_fails_for_symmetric() { + let key = Key::generate(Algorithm::HS256, Some("sym".to_string())).unwrap(); + let set = KeySet { + keys: vec![Arc::new(key)], + }; + + let result = set.to_public_set(); + assert!(result.is_err()); + } + + #[test] + fn test_encode_success() { + let key = create_test_key(Some("1".to_string())); + let set = KeySet { + keys: vec![Arc::new(key)], + }; + let claims = create_test_claims(); + + let token = set.encode(&claims).unwrap(); + assert!(!token.is_empty()); + } + + #[test] + fn test_encode_no_signing_key() { + let mut key = create_test_key(Some("1".to_string())); + key.operations = [KeyOperation::Verify].into(); + let set = KeySet { + keys: vec![Arc::new(key)], + }; + let claims = create_test_claims(); + + let result = set.encode(&claims); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("cannot find signing key")); + } + + #[test] + fn test_decode_success_with_kid() { + let key = create_test_key(Some("1".to_string())); + let set = KeySet { + keys: vec![Arc::new(key)], + }; + let claims = create_test_claims(); + + let token = set.encode(&claims).unwrap(); + let decoded = set.decode(&token).unwrap(); + + assert_eq!(decoded.root, claims.root); + } + + #[test] + fn test_decode_success_single_key_no_kid() { + // Create a key without KID + let key = create_test_key(None); + let claims = create_test_claims(); + + // Encode using the key directly + let token = key.encode(&claims).unwrap(); + + let set = KeySet { + keys: vec![Arc::new(key)], + }; + + // Decode using the set + let decoded = set.decode(&token).unwrap(); + assert_eq!(decoded.root, claims.root); + } + + #[test] + fn test_decode_fail_multiple_keys_no_kid() { + let key1 = create_test_key(None); + let key2 = create_test_key(None); + + let set = KeySet { + keys: vec![Arc::new(key1), Arc::new(key2)], + }; + + let claims = create_test_claims(); + // Encode with one of the keys directly + let token = set.keys[0].encode(&claims).unwrap(); + + let result = set.decode(&token); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("missing kid")); + } + + #[test] + fn test_decode_fail_unknown_kid() { + let key1 = create_test_key(Some("1".to_string())); + let key2 = create_test_key(Some("2".to_string())); + + let set1 = KeySet { + keys: vec![Arc::new(key1)], + }; + let set2 = KeySet { + keys: vec![Arc::new(key2)], + }; + + let claims = create_test_claims(); + let token = set1.encode(&claims).unwrap(); + + let result = set2.decode(&token); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("cannot find key with kid 1")); + } + + #[test] + fn test_file_io() { + let key = create_test_key(Some("1".to_string())); + let set = KeySet { + keys: vec![Arc::new(key)], + }; + + let dir = std::env::temp_dir(); + // Use a random-ish name to avoid collisions + let filename = format!( + "test_keyset_{}.json", + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap() + .as_nanos() + ); + let path = dir.join(filename); + + set.to_file(&path).expect("failed to write to file"); + + let loaded = KeySet::from_file(&path).expect("failed to read from file"); + assert_eq!(loaded.keys.len(), 1); + assert_eq!(loaded.keys[0].kid.as_deref(), Some("1")); + + // Clean up + let _ = std::fs::remove_file(path); + } +} From c9e806d3dc263f331271cfc0a5205c435cd1f157 Mon Sep 17 00:00:00 2001 From: Robin Date: Sat, 17 Jan 2026 15:38:29 +0100 Subject: [PATCH 12/15] Fix doc --- rs/moq-token/src/set.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rs/moq-token/src/set.rs b/rs/moq-token/src/set.rs index 5e6c45991..28754f2a2 100644 --- a/rs/moq-token/src/set.rs +++ b/rs/moq-token/src/set.rs @@ -4,7 +4,7 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer}; use std::path::Path; use std::sync::Arc; -/// JWK Set to spec https://datatracker.ietf.org/doc/html/rfc7517#section-5 +/// JWK Set to spec #[derive(Default, Clone)] pub struct KeySet { /// Vec of an arbitrary number of Json Web Keys From 11312b75e0dc2441390fac132519b81ee11c2627 Mon Sep 17 00:00:00 2001 From: Robin Date: Sat, 17 Jan 2026 16:14:34 +0100 Subject: [PATCH 13/15] AI suggestions --- doc/guide/authentication.md | 4 ++-- rs/moq-relay/src/auth.rs | 4 +++- rs/moq-token/Cargo.toml | 2 +- rs/moq-token/src/set.rs | 11 +++++++++-- 4 files changed, 15 insertions(+), 6 deletions(-) diff --git a/doc/guide/authentication.md b/doc/guide/authentication.md index ef49d89c7..5bd727587 100644 --- a/doc/guide/authentication.md +++ b/doc/guide/authentication.md @@ -13,7 +13,7 @@ The MoQ Relay authenticates via JWT-based tokens. Generally there are two differ 1. Generate a secret key: ```bash -moq-token --key root.jwk generate --algorithm RS256 +moq-token --key root.jwk generate --algorithm HS256 ``` :::details You can also choose a different algorithm - HS256 @@ -79,7 +79,7 @@ moq-token --key private.jwk sign \ ## JWK set authentication -Instead of storing a public key locally in a file, it may also be retrieved from a server hosting a JWK set. This can be a very simple static site serving a JSON file, or a fully OIDC compliant Identity Provider. That way you can easily implement automatic key rotation. +Instead of storing a public key locally in a file, it may also be retrieved from a server hosting a JWK set. This can be a simple static site serving a JSON file, or a fully OIDC compliant Identity Provider. That way you can easily implement automatic key rotation. ::: info This approach only works with asymmetric authentication. diff --git a/rs/moq-relay/src/auth.rs b/rs/moq-relay/src/auth.rs index 6a3f27178..5c3262a4a 100644 --- a/rs/moq-relay/src/auth.rs +++ b/rs/moq-relay/src/auth.rs @@ -79,7 +79,9 @@ pub struct Auth { impl Drop for Auth { fn drop(&mut self) { - if let Some(handle) = self.refresh_task.as_deref() { + if let Some(handle) = self.refresh_task.as_ref() + && Arc::strong_count(handle) == 1 + { handle.abort(); } } diff --git a/rs/moq-token/Cargo.toml b/rs/moq-token/Cargo.toml index b561cc136..2ac7390f1 100644 --- a/rs/moq-token/Cargo.toml +++ b/rs/moq-token/Cargo.toml @@ -19,7 +19,7 @@ elliptic-curve = "0.13.8" jsonwebtoken = { version = "10", features = ["aws_lc_rs"] } p256 = "0.13.2" p384 = "0.13.1" -reqwest = { version = "0.13.0-rc.1", optional = true } +reqwest = { version = "0.13.1", optional = true } rsa = "0.9.10" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/rs/moq-token/src/set.rs b/rs/moq-token/src/set.rs index 28754f2a2..40591c21d 100644 --- a/rs/moq-token/src/set.rs +++ b/rs/moq-token/src/set.rs @@ -3,6 +3,7 @@ use anyhow::Context; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use std::path::Path; use std::sync::Arc; +use std::time::Duration; /// JWK Set to spec #[derive(Default, Clone)] @@ -120,8 +121,14 @@ impl KeySet { #[cfg(feature = "jwks-loader")] pub async fn load_keys(jwks_uri: &str) -> anyhow::Result { - // Fetch the JWKS JSON - let jwks_json = reqwest::get(jwks_uri) + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .build() + .context("failed to build reqwest client")?; + + let jwks_json = client + .get(jwks_uri) + .send() .await .with_context(|| format!("failed to GET JWKS from {}", jwks_uri))? .error_for_status() From 3eeb509a7db2378ccecf7ba1286b5bd5567118f4 Mon Sep 17 00:00:00 2001 From: Robin Date: Sat, 17 Jan 2026 16:53:08 +0100 Subject: [PATCH 14/15] Properly handle errors when initially loading the JWKS --- rs/moq-relay/src/auth.rs | 159 +++++++++++++++++++++------------------ rs/moq-relay/src/main.rs | 2 +- 2 files changed, 85 insertions(+), 76 deletions(-) diff --git a/rs/moq-relay/src/auth.rs b/rs/moq-relay/src/auth.rs index 5c3262a4a..ae048f43c 100644 --- a/rs/moq-relay/src/auth.rs +++ b/rs/moq-relay/src/auth.rs @@ -55,8 +55,8 @@ pub struct AuthConfig { } impl AuthConfig { - pub fn init(self) -> anyhow::Result { - Auth::new(self) + pub async fn init(self) -> anyhow::Result { + Auth::new(self).await } } @@ -91,7 +91,7 @@ impl Auth { fn compare_key_sets(previous: &KeySet, new: &KeySet) { for new_key in new.keys.iter() { if new_key.kid.is_some() && !previous.keys.iter().any(|k| k.kid == new_key.kid) { - tracing::info!("Found new JWK \"{}\"", new_key.kid.as_deref().unwrap()) + tracing::info!("found new JWK \"{}\"", new_key.kid.as_deref().unwrap()) } } } @@ -113,6 +113,8 @@ impl Auth { ) -> tokio::task::JoinHandle<()> { tokio::spawn(async move { loop { + tokio::time::sleep(interval).await; + if let Err(e) = Self::refresh_key_set(&jwks_uri, key_set.as_ref()).await { if interval > JWKS_REFRESH_ERROR_INTERVAL * 2 { tracing::error!( @@ -132,13 +134,11 @@ impl Auth { tracing::error!("failed to refresh JWKS: {:?}", e); } } - - tokio::time::sleep(interval).await; } }) } - pub fn new(config: AuthConfig) -> anyhow::Result { + pub async fn new(config: AuthConfig) -> anyhow::Result { let mut refresh_task = None; let key = match config.key { @@ -146,28 +146,21 @@ impl Auth { // Start with an empty KeySet let key_set = Arc::new(Mutex::new(KeySet::default())); - // TODO Better error handling when initial load fails - match config.jwks_refresh_interval { - Some(refresh_interval_secs) => { - anyhow::ensure!( - refresh_interval_secs >= 30, - "jwks_refresh_interval cannot be less than 30" - ); + tracing::info!("loading JWK set from {}", &uri); - refresh_task = Some(Self::spawn_refresh_task( - Duration::from_secs(refresh_interval_secs), - key_set.clone(), - uri, - )); - } - None => { - let key_set = key_set.clone(); - tokio::spawn(async move { - Self::refresh_key_set(&uri, key_set.as_ref()) - .await - .expect("failed to load key set"); - }); - } + Self::refresh_key_set(&uri, key_set.as_ref()).await?; + + if let Some(refresh_interval_secs) = config.jwks_refresh_interval { + anyhow::ensure!( + refresh_interval_secs >= 30, + "jwks_refresh_interval cannot be less than 30" + ); + + refresh_task = Some(Self::spawn_refresh_task( + Duration::from_secs(refresh_interval_secs), + key_set.clone(), + uri, + )); } Some(key_set) @@ -282,13 +275,14 @@ mod tests { Ok((key_file, key)) } - #[test] - fn test_anonymous_access_with_public_path() -> anyhow::Result<()> { + #[tokio::test] + async fn test_anonymous_access_with_public_path() -> anyhow::Result<()> { // Test anonymous access to /anon path let auth = Auth::new(AuthConfig { public: Some("anon".to_string()), ..Default::default() - })?; + }) + .await?; // Should succeed for anonymous path let token = auth.verify("/anon", None)?; @@ -305,13 +299,14 @@ mod tests { Ok(()) } - #[test] - fn test_anonymous_access_fully_public() -> anyhow::Result<()> { + #[tokio::test] + async fn test_anonymous_access_fully_public() -> anyhow::Result<()> { // Test fully public access (public = "") let auth = Auth::new(AuthConfig { public: Some("".to_string()), ..Default::default() - })?; + }) + .await?; // Should succeed for any path let token = auth.verify("/any/path", None)?; @@ -322,13 +317,14 @@ mod tests { Ok(()) } - #[test] - fn test_anonymous_access_denied_wrong_prefix() -> anyhow::Result<()> { + #[tokio::test] + async fn test_anonymous_access_denied_wrong_prefix() -> anyhow::Result<()> { // Test anonymous access denied for wrong prefix let auth = Auth::new(AuthConfig { public: Some("anon".to_string()), ..Default::default() - })?; + }) + .await?; // Should fail for non-anonymous path let result = auth.verify("/secret", None); @@ -337,13 +333,14 @@ mod tests { Ok(()) } - #[test] - fn test_no_token_no_public_path_fails() -> anyhow::Result<()> { + #[tokio::test] + async fn test_no_token_no_public_path_fails() -> anyhow::Result<()> { let (key_file, _) = create_test_key()?; let auth = Auth::new(AuthConfig { key: Some(key_file.path().to_string_lossy().to_string()), ..Default::default() - })?; + }) + .await?; // Should fail when no token and no public path let result = auth.verify("/any/path", None); @@ -352,12 +349,13 @@ mod tests { Ok(()) } - #[test] - fn test_token_provided_but_no_key_configured() -> anyhow::Result<()> { + #[tokio::test] + async fn test_token_provided_but_no_key_configured() -> anyhow::Result<()> { let auth = Auth::new(AuthConfig { public: Some("anon".to_string()), ..Default::default() - })?; + }) + .await?; // Should fail when token provided but no key configured let result = auth.verify("/any/path", Some("fake-token")); @@ -366,13 +364,14 @@ mod tests { Ok(()) } - #[test] - fn test_jwt_token_basic_validation() -> anyhow::Result<()> { + #[tokio::test] + async fn test_jwt_token_basic_validation() -> anyhow::Result<()> { let (key_file, key) = create_test_key()?; let auth = Auth::new(AuthConfig { key: Some(key_file.path().to_string_lossy().to_string()), ..Default::default() - })?; + }) + .await?; // Create a token with basic permissions let claims = moq_token::Claims { @@ -392,13 +391,14 @@ mod tests { Ok(()) } - #[test] - fn test_jwt_token_wrong_root_path() -> anyhow::Result<()> { + #[tokio::test] + async fn test_jwt_token_wrong_root_path() -> anyhow::Result<()> { let (key_file, key) = create_test_key()?; let auth = Auth::new(AuthConfig { key: Some(key_file.path().to_string_lossy().to_string()), ..Default::default() - })?; + }) + .await?; // Create a token for room/123 let claims = moq_token::Claims { @@ -416,13 +416,14 @@ mod tests { Ok(()) } - #[test] - fn test_jwt_token_with_restricted_publish_subscribe() -> anyhow::Result<()> { + #[tokio::test] + async fn test_jwt_token_with_restricted_publish_subscribe() -> anyhow::Result<()> { let (key_file, key) = create_test_key()?; let auth = Auth::new(AuthConfig { key: Some(key_file.path().to_string_lossy().to_string()), ..Default::default() - })?; + }) + .await?; // Create a token with specific pub/sub restrictions let claims = moq_token::Claims { @@ -442,13 +443,14 @@ mod tests { Ok(()) } - #[test] - fn test_jwt_token_read_only() -> anyhow::Result<()> { + #[tokio::test] + async fn test_jwt_token_read_only() -> anyhow::Result<()> { let (key_file, key) = create_test_key()?; let auth = Auth::new(AuthConfig { key: Some(key_file.path().to_string_lossy().to_string()), ..Default::default() - })?; + }) + .await?; // Create a read-only token (no publish permissions) let claims = moq_token::Claims { @@ -466,13 +468,14 @@ mod tests { Ok(()) } - #[test] - fn test_jwt_token_write_only() -> anyhow::Result<()> { + #[tokio::test] + async fn test_jwt_token_write_only() -> anyhow::Result<()> { let (key_file, key) = create_test_key()?; let auth = Auth::new(AuthConfig { key: Some(key_file.path().to_string_lossy().to_string()), ..Default::default() - })?; + }) + .await?; // Create a write-only token (no subscribe permissions) let claims = moq_token::Claims { @@ -490,13 +493,14 @@ mod tests { Ok(()) } - #[test] - fn test_claims_reduction_basic() -> anyhow::Result<()> { + #[tokio::test] + async fn test_claims_reduction_basic() -> anyhow::Result<()> { let (key_file, key) = create_test_key()?; let auth = Auth::new(AuthConfig { key: Some(key_file.path().to_string_lossy().to_string()), ..Default::default() - })?; + }) + .await?; // Create a token with root at room/123 and unrestricted pub/sub let claims = moq_token::Claims { @@ -519,13 +523,14 @@ mod tests { Ok(()) } - #[test] - fn test_claims_reduction_with_publish_restrictions() -> anyhow::Result<()> { + #[tokio::test] + async fn test_claims_reduction_with_publish_restrictions() -> anyhow::Result<()> { let (key_file, key) = create_test_key()?; let auth = Auth::new(AuthConfig { key: Some(key_file.path().to_string_lossy().to_string()), ..Default::default() - })?; + }) + .await?; // Token allows publishing only to alice/* let claims = moq_token::Claims { @@ -548,13 +553,14 @@ mod tests { Ok(()) } - #[test] - fn test_claims_reduction_with_subscribe_restrictions() -> anyhow::Result<()> { + #[tokio::test] + async fn test_claims_reduction_with_subscribe_restrictions() -> anyhow::Result<()> { let (key_file, key) = create_test_key()?; let auth = Auth::new(AuthConfig { key: Some(key_file.path().to_string_lossy().to_string()), ..Default::default() - })?; + }) + .await?; // Token allows subscribing only to bob/* let claims = moq_token::Claims { @@ -576,13 +582,14 @@ mod tests { Ok(()) } - #[test] - fn test_claims_reduction_loses_access() -> anyhow::Result<()> { + #[tokio::test] + async fn test_claims_reduction_loses_access() -> anyhow::Result<()> { let (key_file, key) = create_test_key()?; let auth = Auth::new(AuthConfig { key: Some(key_file.path().to_string_lossy().to_string()), ..Default::default() - })?; + }) + .await?; // Token allows publishing to alice/* and subscribing to bob/* let claims = moq_token::Claims { @@ -614,13 +621,14 @@ mod tests { Ok(()) } - #[test] - fn test_claims_reduction_nested_paths() -> anyhow::Result<()> { + #[tokio::test] + async fn test_claims_reduction_nested_paths() -> anyhow::Result<()> { let (key_file, key) = create_test_key()?; let auth = Auth::new(AuthConfig { key: Some(key_file.path().to_string_lossy().to_string()), ..Default::default() - })?; + }) + .await?; // Token with nested publish/subscribe paths let claims = moq_token::Claims { @@ -651,13 +659,14 @@ mod tests { Ok(()) } - #[test] - fn test_claims_reduction_preserves_read_write_only() -> anyhow::Result<()> { + #[tokio::test] + async fn test_claims_reduction_preserves_read_write_only() -> anyhow::Result<()> { let (key_file, key) = create_test_key()?; let auth = Auth::new(AuthConfig { key: Some(key_file.path().to_string_lossy().to_string()), ..Default::default() - })?; + }) + .await?; // Read-only token let claims = moq_token::Claims { diff --git a/rs/moq-relay/src/main.rs b/rs/moq-relay/src/main.rs index 0411ea82f..b188ce984 100644 --- a/rs/moq-relay/src/main.rs +++ b/rs/moq-relay/src/main.rs @@ -43,7 +43,7 @@ async fn main() -> anyhow::Result<()> { client.with_iroh(iroh); } - let auth = config.auth.init()?; + let auth = config.auth.init().await?; let cluster = Cluster::new(config.cluster, client); let cloned = cluster.clone(); From 493054ecad62e26e2f58edbdca89be83c5be6bb6 Mon Sep 17 00:00:00 2001 From: Robin Date: Sat, 17 Jan 2026 17:12:17 +0100 Subject: [PATCH 15/15] Small improvements --- rs/moq-relay/src/auth.rs | 5 +++++ rs/moq-token/src/set.rs | 5 +---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/rs/moq-relay/src/auth.rs b/rs/moq-relay/src/auth.rs index ae048f43c..45b48f04b 100644 --- a/rs/moq-relay/src/auth.rs +++ b/rs/moq-relay/src/auth.rs @@ -94,6 +94,11 @@ impl Auth { tracing::info!("found new JWK \"{}\"", new_key.kid.as_deref().unwrap()) } } + for old_key in previous.keys.iter() { + if old_key.kid.is_some() && !new.keys.iter().any(|k| k.kid == old_key.kid) { + tracing::info!("removed JWK \"{}\"", old_key.kid.as_deref().unwrap()) + } + } } async fn refresh_key_set(jwks_uri: &str, key_set: &Mutex) -> anyhow::Result<()> { diff --git a/rs/moq-token/src/set.rs b/rs/moq-token/src/set.rs index 40591c21d..9b8778842 100644 --- a/rs/moq-token/src/set.rs +++ b/rs/moq-token/src/set.rs @@ -81,10 +81,7 @@ impl KeySet { } pub fn find_key(&self, kid: &str) -> Option> { - self.keys - .iter() - .find(|k| k.kid.is_some() && k.kid.as_deref().unwrap() == kid) - .cloned() + self.keys.iter().find(|k| k.kid.as_deref() == Some(kid)).cloned() } pub fn find_supported_key(&self, operation: &KeyOperation) -> Option> {