Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
505 changes: 505 additions & 0 deletions Cargo.lock

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ members = [
"moq-native-ietf",
"moq-catalog",
"moq-test-client",
"moq-auth",
"moq-auth-cat",
]
resolver = "2"

Expand Down
26 changes: 26 additions & 0 deletions moq-auth-cat/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# SPDX-FileCopyrightText: 2024-2026 Cloudflare Inc. and contributors
# SPDX-License-Identifier: MIT OR Apache-2.0

[package]
name = "moq-auth-cat"
description = "C4M (CAT for MoQ) authentication hook for MoQ relay"
authors = ["moq-rs contributors"]
repository = "https://github.com/cloudflare/moq-rs"
license = "MIT OR Apache-2.0"
version = "0.1.0"
edition = "2021"

keywords = ["quic", "moq", "auth", "cat", "c4m"]
categories = ["authentication", "network-programming"]

[dependencies]
moq-auth = { path = "../moq-auth", version = "0.1" }
moq-transport = { path = "../moq-transport", version = "0.14" }
cat-token = "0.1.3"
async-trait = "0.1"
bytes = "1"
anyhow = "1"
tracing = { workspace = true }

[dev-dependencies]
tokio = { version = "1", features = ["rt", "macros"] }
43 changes: 43 additions & 0 deletions moq-auth-cat/src/config.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// SPDX-FileCopyrightText: 2024-2026 Cloudflare Inc. and contributors
// SPDX-License-Identifier: MIT OR Apache-2.0

use std::sync::Arc;

use cat_token::{CatTokenValidator, CryptographicAlgorithm, MoqtValidator};

/// Configuration for building a C4MAuthHook.
pub struct C4MConfig {
pub(crate) algorithm: Arc<dyn CryptographicAlgorithm + Send + Sync>,
pub(crate) token_validator: CatTokenValidator,
pub(crate) moqt_validator: MoqtValidator,
}

impl C4MConfig {
pub fn new(algorithm: impl CryptographicAlgorithm + Send + Sync + 'static) -> Self {
Self {
algorithm: Arc::new(algorithm),
token_validator: CatTokenValidator::new(),
moqt_validator: MoqtValidator::new(),
}
}

pub fn with_expected_issuers(mut self, issuers: Vec<String>) -> Self {
self.token_validator = self.token_validator.with_expected_issuers(issuers);
self
}

pub fn with_expected_audiences(mut self, audiences: Vec<String>) -> Self {
self.token_validator = self.token_validator.with_expected_audiences(audiences);
self
}

pub fn with_clock_skew_tolerance(mut self, seconds: i64) -> Self {
self.token_validator = self.token_validator.with_clock_skew_tolerance(seconds);
self
}

pub fn with_min_revalidation_interval(mut self, seconds: f64) -> Self {
self.moqt_validator = self.moqt_validator.with_min_revalidation_interval(seconds);
self
}
}
26 changes: 26 additions & 0 deletions moq-auth-cat/src/error.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// SPDX-FileCopyrightText: 2024-2026 Cloudflare Inc. and contributors
// SPDX-License-Identifier: MIT OR Apache-2.0

use cat_token::CatError;
use moq_auth::DenyReason;

pub(crate) fn map_cat_error(err: CatError) -> DenyReason {
match err {
CatError::TokenExpired => DenyReason::TokenExpired,
CatError::TokenNotYetValid => DenyReason::TokenExpired,
CatError::InvalidIssuer => DenyReason::IssuerUnknown,
CatError::InvalidAudience => DenyReason::TokenInvalid,
CatError::SignatureVerificationFailed => DenyReason::TokenInvalid,
CatError::MoqtActionNotAuthorized(_) => DenyReason::ScopeMismatch,
CatError::InvalidTokenFormat => DenyReason::TokenMalformed,
CatError::InvalidCbor(_) => DenyReason::TokenMalformed,
CatError::InvalidBase64(_) => DenyReason::TokenMalformed,
CatError::MissingRequiredClaim(_) => DenyReason::TokenMalformed,
CatError::ReplayAttackDetected => DenyReason::TokenReplayed,
CatError::UnsupportedAlgorithm(_) => DenyReason::TokenInvalid,
CatError::AlgorithmMismatch { .. } => DenyReason::TokenInvalid,
_ => DenyReason::Other {
message: format!("{err}"),
},
}
}
139 changes: 139 additions & 0 deletions moq-auth-cat/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
// SPDX-FileCopyrightText: 2024-2026 Cloudflare Inc. and contributors
// SPDX-License-Identifier: MIT OR Apache-2.0

//! C4M (CAT for MoQ) authentication hook for the MoQ relay.
//!
//! Implements [draft-ietf-moq-c4m](https://datatracker.ietf.org/doc/draft-ietf-moq-c4m/)
//! using the [`cat-token`](https://crates.io/crates/cat-token) crate.

mod config;
mod error;
mod mapping;

#[cfg(test)]
mod tests;

pub use config::C4MConfig;
pub use cat_token::Es256Algorithm;

use std::sync::Arc;

use async_trait::async_trait;
use cat_token::{
CatTokenValidator, CryptographicAlgorithm, MoqtAction, MoqtAuthRequest, MoqtValidator,
decode_token_bytes,
};
use moq_auth::{AuthBlob, AuthDecision, AuthHook, DenyReason, RequestContext, SessionContext};

use crate::error::map_cat_error;
use crate::mapping::map_operation;

pub use cat_token::C4M_TOKEN_TYPE;

/// C4M authentication hook implementing the CAT for MoQ auth scheme.
///
/// Validates CWT tokens carrying MOQT-specific claims (namespace/track
/// scope matching) using the `cat-token` library.
pub struct C4MAuthHook {
algorithm: Arc<dyn CryptographicAlgorithm + Send + Sync>,
token_validator: CatTokenValidator,
moqt_validator: MoqtValidator,
}

impl C4MAuthHook {
pub fn new(config: C4MConfig) -> Self {
Self {
algorithm: config.algorithm,
token_validator: config.token_validator,
moqt_validator: config.moqt_validator,
}
}

fn find_c4m_blob<'a>(&self, tokens: &'a [AuthBlob]) -> Option<&'a AuthBlob> {
tokens.iter().find(|t| t.token_type == C4M_TOKEN_TYPE)
}

fn validate_and_authorize(
&self,
blob: &AuthBlob,
action: MoqtAction,
namespace: Vec<Vec<u8>>,
track: Vec<u8>,
) -> Result<AuthDecision, DenyReason> {
let token = decode_token_bytes(&blob.token_value, self.algorithm.as_ref()).map_err(
|e| {
tracing::debug!(error = %e, "C4M token decode/signature failed");
map_cat_error(e)
},
)?;

self.token_validator.validate(&token).map_err(|e| {
tracing::debug!(error = %e, sub = ?token.informational.sub, "C4M claims validation failed");
map_cat_error(e)
})?;

self.moqt_validator.validate_moqt_claims(&token).map_err(|e| {
tracing::debug!(error = %e, sub = ?token.informational.sub, "C4M MOQT claims invalid");
map_cat_error(e)
})?;

let request = MoqtAuthRequest::new(action.clone(), namespace.clone(), track.clone());
let result = self.moqt_validator.authorize(&token, &request);

if result.authorized {
let principal = token.informational.sub.clone();
tracing::debug!(
sub = ?principal,
action = ?action,
"C4M auth: allowed"
);
Ok(AuthDecision::allow().with_principal(principal))
} else {
tracing::debug!(
sub = ?token.informational.sub,
action = ?action,
namespace = ?namespace.iter().map(|n| String::from_utf8_lossy(n).to_string()).collect::<Vec<_>>(),
"C4M auth: denied (scope mismatch)"
);
Err(DenyReason::ScopeMismatch)
}
}
}

#[async_trait]
impl AuthHook for C4MAuthHook {
async fn on_setup(
&self,
_ctx: &SessionContext,
tokens: &[AuthBlob],
) -> anyhow::Result<AuthDecision> {
let Some(blob) = self.find_c4m_blob(tokens) else {
return Ok(AuthDecision::deny(DenyReason::TokenMissing));
};

match self.validate_and_authorize(blob, MoqtAction::ClientSetup, vec![], vec![]) {
Ok(decision) => Ok(decision),
Err(reason) => Ok(AuthDecision::deny(reason)),
}
}

async fn on_request(
&self,
ctx: &RequestContext<'_>,
tokens: &[AuthBlob],
) -> anyhow::Result<AuthDecision> {
let Some(blob) = self.find_c4m_blob(tokens) else {
return Ok(AuthDecision::deny(DenyReason::TokenMissing));
};

let Some((action, namespace, track)) = map_operation(&ctx.operation) else {
tracing::debug!(operation = ?ctx.operation, "C4M auth: unknown operation, denying");
return Ok(AuthDecision::deny(DenyReason::ScopeMismatch));
};

match self.validate_and_authorize(blob, action, namespace, track) {
Ok(decision) => Ok(decision),
Err(reason) => Ok(AuthDecision::deny(reason)),
}
}
}
42 changes: 42 additions & 0 deletions moq-auth-cat/src/mapping.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// SPDX-FileCopyrightText: 2024-2026 Cloudflare Inc. and contributors
// SPDX-License-Identifier: MIT OR Apache-2.0

use cat_token::MoqtAction;
use moq_auth::AuthzOperation;
use moq_transport::coding::TrackNamespace;

/// Maps an AuthzOperation to the (MoqtAction, namespace_tuple, track) triple
/// expected by cat-token's MoqtAuthRequest.
///
/// Returns `None` for unknown operations, which the caller must treat as deny.
pub(crate) fn map_operation(op: &AuthzOperation<'_>) -> Option<(MoqtAction, Vec<Vec<u8>>, Vec<u8>)> {
match op {
AuthzOperation::Publish { namespace, track } => {
Some((MoqtAction::Publish, ns_to_tuple(namespace), track.to_vec()))
}
AuthzOperation::PublishNamespace { namespace } => {
Some((MoqtAction::PublishNamespace, ns_to_tuple(namespace), vec![]))
}
AuthzOperation::PublishNamespaceDone { namespace } => {
Some((MoqtAction::PublishNamespace, ns_to_tuple(namespace), vec![]))
}
AuthzOperation::Subscribe { namespace, track } => {
Some((MoqtAction::Subscribe, ns_to_tuple(namespace), track.to_vec()))
}
AuthzOperation::SubscribeNamespace { prefix } => {
Some((MoqtAction::SubscribeNamespace, ns_to_tuple(prefix), vec![]))
}
AuthzOperation::Fetch { namespace, track } => {
Some((MoqtAction::Fetch, ns_to_tuple(namespace), track.to_vec()))
}
AuthzOperation::TrackStatus { namespace, track } => {
Some((MoqtAction::TrackStatus, ns_to_tuple(namespace), track.to_vec()))
}
AuthzOperation::RequestUpdate { .. } => Some((MoqtAction::RequestUpdate, vec![], vec![])),
_ => None,
}
}

fn ns_to_tuple(ns: &TrackNamespace) -> Vec<Vec<u8>> {
ns.fields.iter().map(|f| f.value.clone()).collect()
}
Loading