open-standards/joule-code/joule-code-rs/crates/joule-moq-ingest/tests/auth_and_ice.rs
Transaction Science 161c4f1e31 joule-moq-ingest: the HTTP proof step on its own, its corpus vectors, and the tests on proofs
check_http_proof is authorize()'s proof step as a function (live-rooms §10.5.3, §10.5.4).
room_corpus runs the corpus's six HTTP proofs through it with the gateway's own reading of
each grant, and presents each accepted proof again as a replay. auth_and_ice presents
grants with proofs through the Auth helper, and shows the session's PATCH and DELETE
refusing a grant presented alone.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 19:14:04 -04:00

187 lines
9.3 KiB
Rust

//! Refusals at the WHIP and WHEP resources, by RFC 9725, `draft-ietf-wish-whep-05` and
//! RFC 6750; trickle ICE and an ICE restart over PATCH (RFC 9725 §4.3), with the media
//! carried across the restart.
mod common;
use common::*;
use ed25519_dalek::SigningKey;
use joule_moq_ingest::frame::Codec;
use joule_moq_ingest::room::build;
use joule_moq_ingest::sdp;
use std::time::Duration;
use str0m::media::Direction;
use str0m::IceConnectionState;
use tokio::time::timeout;
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn tokens_without_the_power_are_refused() {
timeout(Duration::from_secs(120), refusals()).await.expect("the test finishes in time");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn trickle_ice_and_an_ice_restart_over_patch() {
timeout(Duration::from_secs(120), ice()).await.expect("the test finishes in time");
}
async fn post_whip(w: &World, auth: Option<&str>, ct: &str, body: &[u8]) -> Resp {
let a = auth.map(bearer);
let mut h = vec![("content-type", ct)];
if let Some(a) = &a {
h.push(("authorization", a.as_str()));
}
http("POST", &format!("{}/whip", w.base()), &h, body).await
}
async fn refusals() {
let w = World::start(|_| {}).await;
let o = offer(&[Codec::Avc, Codec::Opus], Direction::SendOnly, true, true).await;
let ob = o.offer.as_bytes();
// No token: 401, the challenge without an error (RFC 6750 §3.1).
let r = post_whip(&w, None, "application/sdp", ob).await;
assert_eq!(r.status, 401);
assert_eq!(r.header("www-authenticate"), Some("Bearer realm=\"wai-ingest\""));
// A token that is not a grant: 401 invalid_token.
let r = post_whip(&w, Some("bm90LWEtZ3JhbnQ"), "application/sdp", ob).await;
assert_eq!(r.status, 401);
assert!(r.header("www-authenticate").unwrap().contains("error=\"invalid_token\""));
// A viewer's grant, its key proved, has no publish power: 403 insufficient_scope.
let whip = format!("{}/whip", w.base());
let viewer = SigningKey::from_bytes(&[0x61; 32]);
let va = w.auth(&viewer, 1);
let r = va.send("POST", &whip, &[("content-type", "application/sdp")], ob).await;
assert_eq!(r.status, 403, "{}", r.text());
assert!(r.header("www-authenticate").unwrap().contains("error=\"insufficient_scope\""));
assert!(r.text().contains("publish_not_permitted"));
assert_eq!(r.header("content-type"), Some("application/problem+json"));
// A grant past its window, and one of a room the gateway does not hold.
let s = now_us() / 1_000_000;
let old = build::grant(&w.descriptor, &w.authority, &w.publisher.verifying_key().to_bytes(), 0, s - 7200, s - 3600);
let r = post_whip(&w, Some(&World::token(&old)), "application/sdp", ob).await;
assert_eq!(r.status, 401);
assert!(r.text().contains("outside_grant_window"), "{}", r.text());
let other_authority = SigningKey::from_bytes(&[0x77; 32]);
let other = build::room(&other_authority, now_us());
let og = build::grant(&other, &other_authority, &w.publisher.verifying_key().to_bytes(), 0, s - 60, s + 60);
let r = post_whip(&w, Some(&World::token(&og)), "application/sdp", ob).await;
assert_eq!(r.status, 401);
assert!(r.text().contains("room_unknown"), "{}", r.text());
// A good grant, its key proved, but not an SDP body: 415.
let pa = w.auth(&w.publisher, 0);
let r = pa.send("POST", &whip, &[("content-type", "text/plain")], ob).await;
assert_eq!(r.status, 415);
// An offer with a data channel only: 422.
let r = pa.send("POST", &whip, &[("content-type", "application/sdp")], b"v=0\r\no=- 1 1 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\nm=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\na=mid:0\r\n").await;
assert_eq!(r.status, 422);
// WHEP: an observer's grant may not subscribe; the viewer's may not go above its
// max_layer.
let observer = SigningKey::from_bytes(&[0x62; 32]);
let oa = w.auth(&observer, 2);
let po = offer(&[Codec::Avc, Codec::Opus], Direction::RecvOnly, true, true).await;
let r = oa.send("POST", &w.whep_url(), &[("content-type", "application/sdp")], po.offer.as_bytes()).await;
assert_eq!(r.status, 403);
assert!(r.text().contains("subscribe_not_permitted"));
let r = va.send("POST", &format!("{}?layer=1", w.whep_url()), &[("content-type", "application/sdp")], po.offer.as_bytes()).await;
assert_eq!(r.status, 403);
assert!(r.text().contains("layer_not_permitted"));
// Discovery and CORS.
let r = http("HEAD", &w.whep_url(), &[], b"").await;
assert_eq!(r.status, 200);
assert_eq!(r.header("link"), Some("<urn:ietf:params:whep>; rel=\"profile\""));
let r = http("OPTIONS", &format!("{}/whip", w.base()), &[], b"").await;
assert_eq!((r.status, r.header("accept-post")), (200, Some("application/sdp")));
// No session was opened by any of these.
assert_eq!(w.gateway.gateway.records().len(), 0);
w.stop().await;
}
async fn ice() {
let w = World::start(|_| {}).await;
let a = w.auth(&w.publisher, 0);
let p = publish(&w, &a).await;
let watcher = watch(&w).await;
let url = format!("{}{}", w.base(), p.location);
let mut sent = Sent::default();
send(&p, &mut sent, 1, 15).await;
let frag = "a=ice-ufrag:{u}\r\na=ice-pwd:{p}\r\nm=audio 9 UDP/TLS/RTP/SAVPF 111\r\na=mid:0\r\na=candidate:9 1 udp 2130706431 127.0.0.1 9 typ host\r\n";
let (ufrag, pwd) = (sdp::attr(&p_offer_creds(&p), "ice-ufrag").unwrap().to_string(), sdp::attr(&p_offer_creds(&p), "ice-pwd").unwrap().to_string());
let trickle = frag.replace("{u}", &ufrag).replace("{p}", &pwd);
let ct = "application/trickle-ice-sdpfrag";
// A PATCH names the ICE session it is for: missing, 428; another, 412.
let r = a.send("PATCH", &url, &[("content-type", ct)], trickle.as_bytes()).await;
assert_eq!(r.status, 428);
let r = a.send("PATCH", &url, &[("content-type", ct), ("if-match", "\"not-this-one\"")], trickle.as_bytes()).await;
assert_eq!(r.status, 412);
// Trickle ICE: 204, no ETag.
let etag = format!("\"{}\"", p.etag);
let r = a.send("PATCH", &url, &[("content-type", ct), ("if-match", &etag)], trickle.as_bytes()).await;
assert_eq!(r.status, 204);
assert!(r.header("etag").is_none());
// Another principal's grant, its own key proved: 403.
let other = w.auth(&SigningKey::from_bytes(&[0x63; 32]), 0);
let r = other.send("PATCH", &url, &[("content-type", ct), ("if-match", "*")], trickle.as_bytes()).await;
assert_eq!(r.status, 403);
// An ICE restart: new credentials, If-Match *; 200 with the gateway's new ones and a
// new entity-tag.
let connected_before = p.count(|e| matches!(e, PeerEvent::Ice(IceConnectionState::Connected) | PeerEvent::Ice(IceConnectionState::Completed)));
let new_offer = p.restart_offer().await;
let body = sdp::client_restart_frag(&new_offer);
let r = a.send("PATCH", &url, &[("content-type", ct), ("if-match", "*")], body.as_bytes()).await;
assert_eq!(r.status, 200, "{}", r.text());
assert_eq!(r.header("content-type"), Some(ct));
let new_etag = r.header("etag").expect("a new entity-tag").to_string();
assert_ne!(new_etag, etag);
let f = sdp::parse_frag(&r.text()).expect("the answer is a fragment");
assert!(f.end_of_candidates && !f.candidates.is_empty());
assert_ne!(f.ufrag.as_deref(), sdp::attr(&p.answer, "ice-ufrag"), "the gateway's credentials changed");
p.restarted(r.text()).await.expect("the client applies the restart");
assert!(
until(STEP, || p.count(|e| matches!(e, PeerEvent::Ice(IceConnectionState::Connected) | PeerEvent::Ice(IceConnectionState::Completed))) > connected_before).await,
"ICE connects again"
);
// The old entity-tag is stale now.
let r = a.send("PATCH", &url, &[("content-type", ct), ("if-match", &etag)], trickle.as_bytes()).await;
assert_eq!(r.status, 412);
// The session's resource asks for the proof too: a PATCH or a DELETE that presents the
// grant alone is refused (live-rooms §10.5.4 step 1), and the session stays.
let bare = bearer(&a.token());
let r = http("PATCH", &url, &[("content-type", ct), ("authorization", &bare), ("if-match", "*")], trickle.as_bytes()).await;
assert_eq!(r.status, 401);
assert!(r.text().contains("pop_missing"), "{}", r.text());
let r = http("DELETE", &url, &[("authorization", &bare)], b"").await;
assert_eq!(r.status, 401);
assert!(r.text().contains("pop_missing"), "{}", r.text());
// Media after the restart reaches the relay with the rest.
send(&p, &mut sent, 1, 15).await;
assert!(received(&w, &p, sent.video.len() + sent.audio.len()).await, "every frame reached the gateway");
assert_eq!(delete(&w, &p.location, &a).await.status, 200);
assert!(until(STEP, || watcher.ended.lock().unwrap().len() == 2).await);
settle(&watcher, Duration::from_millis(500)).await;
let video = watcher.tracks.iter().find(|t| t.kind == 2).unwrap();
let audio = watcher.tracks.iter().find(|t| t.kind == 1).unwrap();
let (vf, _) = verified(&watcher, video, &w.gateway_key, Codec::Avc);
let (af, _) = verified(&watcher, audio, &w.gateway_key, Codec::Opus);
same(&vf, &sent.video, "every video frame, before and after the restart");
same(&af, &sent.audio, "every audio packet, before and after the restart");
p.close();
w.stop().await;
}
/// The client's own ICE credentials: the gateway holds them as the offer's.
fn p_offer_creds(p: &Peer) -> String {
p.offer.clone()
}