From 05a77cc505e2ef54abb4c44f186ff52e2335eae9 Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 31 Dec 2025 00:26:01 +0100 Subject: [PATCH 01/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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,