diff --git a/doc/guide/authentication.md b/doc/guide/authentication.md new file mode 100644 index 000000000..ef49d89c7 --- /dev/null +++ b/doc/guide/authentication.md @@ -0,0 +1,133 @@ +--- +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: +- 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/** + +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/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..2720106df 100644 --- a/rs/moq-relay/src/auth.rs +++ b/rs/moq-relay/src/auth.rs @@ -1,8 +1,10 @@ -use std::sync::Arc; - +use anyhow::Context; use axum::http; use moq_lite::{AsPath, Path, PathOwned}; +use moq_token::KeySet; use serde::{Deserialize, Serialize}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; #[derive(thiserror::Error, Debug, Clone)] pub enum AuthError { @@ -34,11 +36,17 @@ 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")] pub key: Option, + /// 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, defaults to None + #[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. /// If a user provides a token, then they can only access the prefix only if it is specified in the token. @@ -60,16 +68,117 @@ pub struct AuthToken { pub cluster: bool, } +const JWKS_REFRESH_ERROR_INTERVAL: Duration = Duration::from_mins(5); + #[derive(Clone)] 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.as_deref() { + handle.abort(); + } + } } 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()) + } + } + } + + async fn refresh_key_set(jwks_uri: &str, key_set: &Mutex) -> anyhow::Result<()> { + let new_keys = moq_token::load_keys(jwks_uri).await?; + + 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, + key_set: Arc>, + jwks_uri: String, + ) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + loop { + 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: {:?}", + JWKS_REFRESH_ERROR_INTERVAL.as_secs(), + e + ); + tokio::time::sleep(JWKS_REFRESH_ERROR_INTERVAL).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"); + } + } 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)?), + let mut refresh_task = None; + + let key = match config.key { + Some(uri) if uri.starts_with("http://") || uri.starts_with("https://") => { + // 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"); + }); + } + } + + 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(Mutex::new(KeySet { + keys: vec![Arc::new(key)], + }))) + } + None => None, }; @@ -82,8 +191,9 @@ impl Auth { } Ok(Self { - key: key.map(Arc::new), + key, public: public.map(|p| p.as_path().to_owned()), + refresh_task: refresh_task.map(Arc::new), }) } @@ -93,8 +203,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); } @@ -119,7 +232,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() @@ -172,8 +285,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, public: Some("anon".to_string()), + ..Default::default() })?; // Should succeed for anonymous path @@ -195,8 +308,8 @@ mod tests { fn test_anonymous_access_fully_public() -> anyhow::Result<()> { // Test fully public access (public = "") let auth = Auth::new(AuthConfig { - key: None, public: Some("".to_string()), + ..Default::default() })?; // Should succeed for any path @@ -212,8 +325,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, public: Some("anon".to_string()), + ..Default::default() })?; // Should fail for non-anonymous path @@ -228,7 +341,7 @@ mod tests { let (key_file, _) = create_test_key()?; let auth = Auth::new(AuthConfig { key: Some(key_file.path().to_string_lossy().to_string()), - public: None, + ..Default::default() })?; // Should fail when no token and no public path @@ -241,8 +354,8 @@ mod tests { #[test] fn test_token_provided_but_no_key_configured() -> anyhow::Result<()> { let auth = Auth::new(AuthConfig { - key: None, public: Some("anon".to_string()), + ..Default::default() })?; // Should fail when token provided but no key configured @@ -257,7 +370,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()), - public: None, + ..Default::default() })?; // Create a token with basic permissions @@ -283,7 +396,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()), - public: None, + ..Default::default() })?; // Create a token for room/123 @@ -307,7 +420,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()), - public: None, + ..Default::default() })?; // Create a token with specific pub/sub restrictions @@ -333,7 +446,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()), - public: None, + ..Default::default() })?; // Create a read-only token (no publish permissions) @@ -357,7 +470,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()), - public: None, + ..Default::default() })?; // Create a write-only token (no subscribe permissions) @@ -381,7 +494,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()), - public: None, + ..Default::default() })?; // Create a token with root at room/123 and unrestricted pub/sub @@ -410,7 +523,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()), - public: None, + ..Default::default() })?; // Token allows publishing only to alice/* @@ -439,7 +552,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()), - public: None, + ..Default::default() })?; // Token allows subscribing only to bob/* @@ -467,7 +580,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()), - public: None, + ..Default::default() })?; // Token allows publishing to alice/* and subscribing to bob/* @@ -505,7 +618,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()), - public: None, + ..Default::default() })?; // Token with nested publish/subscribe paths @@ -542,7 +655,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()), - public: None, + ..Default::default() })?; // Read-only token diff --git a/rs/moq-token/Cargo.toml b/rs/moq-token/Cargo.toml index 6e6e79fb2..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,6 +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 } rsa = "0.9.9" serde = { version = "1", features = ["derive"] } serde_json = "1" 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..c6351ec68 --- /dev/null +++ b/rs/moq-token/src/set.rs @@ -0,0 +1,135 @@ +use crate::{Claims, Key, KeyOperation}; +use anyhow::Context; +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 +#[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::>, _>>()?, + }) + } + + pub fn find_key(&self, kid: &str) -> Option> { + self.keys + .iter() + .find(|k| k.kid.is_some() && k.kid.as_deref().unwrap() == kid) + .cloned() + } + + pub fn find_supported_key(&self, operation: &KeyOperation) -> Option> { + self.keys.iter().find(|key| key.operations.contains(operation)).cloned() + } + + pub fn encode(&self, payload: &Claims) -> anyhow::Result { + let key = self + .find_supported_key(&KeyOperation::Sign) + .context("cannot find signing key")?; + key.encode(payload) + } + + pub fn decode(&self, token: &str) -> anyhow::Result { + let header = jsonwebtoken::decode_header(token).context("failed to decode JWT header")?; + + 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") + } + } + }?; + + key.decode(token) + } +} + +#[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) + .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") +}