From 741e1f757285b815f3f59bb855b2ad69656e0e21 Mon Sep 17 00:00:00 2001 From: Luke Curley <432854+kixelated@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:25:29 -0700 Subject: [PATCH 01/14] quest: claim live request auth updates From 9ab68fdec69f41260c5a6fde3514579f0ab0f564 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 16 Oct 2026 09:23:40 -0700 Subject: [PATCH 02/14] quest: record the auth::Auth decision for request updates Co-Authored-By: Claude Opus 5.5 --- quest/m1/auth/request-update.md | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/quest/m1/auth/request-update.md b/quest/m1/auth/request-update.md index e7ac3b1894b..4d69fc06c0c 100644 --- a/quest/m1/auth/request-update.md +++ b/quest/m1/auth/request-update.md @@ -21,10 +21,16 @@ because it has a consumer now. The decisions are recorded in the - ✅ Decided 2026-10-15 (maintainer): general request updates, not a per-request token setter. Each `Route`/`Subscription` carries an - `Option` (name to be settled), and changing it triggers the - update automatically. Unset means the session's scope. + `Option`, and changing it triggers the update automatically. + Unset means the session's scope. - ✅ Decided 2026-10-15 (maintainer): outgoing tokens go through `add()`; #5148's `set_request_token` and its renewal sender are not carried forward. +- ✅ Decided 2026-10-16 (maintainer, #5695): `auth::Handle::add()` returns + `auth::Auth`, a cloneable reference to one presented credential that stays + presented until its last reference drops; `Route::auth` and + `Subscription::auth` hold `Option`. Not a cloneable `auth::Token`, + which conflates the credential bytes with the auth a request names, and + not credential bytes on the request, which bypass `add()`. - Renewing many live requests at once must not take one round trip per request: each request's update is its own in-flight step, polled beside the session's other announce and subscribe work, so new announcements and @@ -44,8 +50,12 @@ because it has a consumer now. The decisions are recorded in the lite it names the auth registered through [Requests name their auth](/quest/m1/auth/request-auth-id.md), so the lite path follows that quest. A refused update ends only that request. -- Settle the type and field names with the maintainer before implementing; - mirror them in `js/net` and the bindings. +- Mirror `Auth`, `Route::auth` and `Subscription::auth` in `js/net` and the + bindings. +- Open (2026-10-16): a `Route` lives on an origin shared by every session it + feeds, including a reconnecting connection's later sessions, while `add()` + belongs to one session's handle. Which sessions may present a route's + `Auth` is unsettled; see #5695. - Describe request renewal in `doc/concept/moq-lite.md`'s Authorization section, and in `doc/concept/standard.md`, which describes only incoming request tokens; the moq-transport path can land before the lite one. From 859c1bd0add82082d6a77296cff72c4bc528b4f1 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 16 Oct 2026 09:36:30 -0700 Subject: [PATCH 03/14] quest: settle request-update auth binding and scope Co-Authored-By: Claude Opus 5.5 --- quest/m1/auth/README.md | 2 +- quest/m1/auth/request-update.md | 38 +++++++++++++++++++-------------- 2 files changed, 23 insertions(+), 17 deletions(-) diff --git a/quest/m1/auth/README.md b/quest/m1/auth/README.md index a652615ee32..8a7bf36ff46 100644 --- a/quest/m1/auth/README.md +++ b/quest/m1/auth/README.md @@ -130,7 +130,7 @@ because `main` assigned 0x3A to NOT_FETCHABLE first. ## Required -- [Live requests follow their auth](/quest/m1/auth/request-update.md) - a route or subscription swaps its auth and the session sends the update, keeping the request up +- [Live requests follow their auth](/quest/m1/auth/request-update.md) - a route swaps its auth and the session sends the update, keeping the request up - [Requests name their auth](/quest/m1/auth/request-auth-id.md) - lite-07 AUTH carries an ID and each request names the auth that admits it - [Request gate](/quest/m1/auth/request-gate.md) - every incoming request is refused up front and held to the grant through a gate its dispatcher owns diff --git a/quest/m1/auth/request-update.md b/quest/m1/auth/request-update.md index 4d69fc06c0c..5b452dc39a9 100644 --- a/quest/m1/auth/request-update.md +++ b/quest/m1/auth/request-update.md @@ -1,13 +1,13 @@ -# [M] Live requests follow their auth +# [L] Live requests follow their auth ## Goal -A publisher or subscriber renews the credential behind a live request -without reopening it. A `Route` or `Subscription` holds an optional -`moq_net::Auth`; when the application swaps it, the session sends -REQUEST_UPDATE (moq-transport) naming the new auth; on lite a `Route` renews -via ANNOUNCE_UPDATE and a `Subscription` via SUBSCRIBE_UPDATE. The request -stays up. The first consumer is a client that keeps a +A publisher renews the credential behind a live announcement without +reopening it. A `Route` holds an optional `moq_net::auth::Auth`; when the +application swaps it, the session sends REQUEST_UPDATE (moq-transport) +carrying the new auth, and the request stays up. Lite renews by +ANNOUNCE_UPDATE once [Requests name their auth](/quest/m1/auth/request-auth-id.md) +gives it a way to name one. The first consumer is a client that keeps a PUBLISH_NAMESPACE alive past its credential's expiry by sending REQUEST_UPDATE with a fresh token before it expires. @@ -31,6 +31,19 @@ because it has a consumer now. The decisions are recorded in the `Subscription::auth` hold `Option`. Not a cloneable `auth::Token`, which conflates the credential bytes with the auth a request names, and not credential bytes on the request, which bypass `add()`. +- ✅ Decided 2026-10-16 (maintainer, #5695): an `Auth` is bound to the auth + handle that issued it. A `Route` lives on an origin that outlives any one + session, so a connection-level handle spans that connection's sessions: + `moq_tokio::Connection::auth().add()` returns a `moq_net::auth::Auth` every + later session of that connection presents. Any other session sends the + request with no auth, under its scope. Not presented by any session, which + would send the credential to every peer the origin feeds, and not bound to + one session, which would make the app re-add and re-announce after every + reconnect. moq-tokio's `connection::Auth` and `connection::Token` are + renamed so the names match across layers. +- ✅ Decided 2026-10-16 (maintainer, #5695): `Route::auth` only. A + `Subscription` is aggregated across subscribers before it reaches the + wire, so `Subscription::auth` and its aggregation rule are a follow-up. - Renewing many live requests at once must not take one round trip per request: each request's update is its own in-flight step, polled beside the session's other announce and subscribe work, so new announcements and @@ -42,20 +55,13 @@ because it has a consumer now. The decisions are recorded in the sessions sharing an origin, so the cost grows only with the requests renewed. (From the deleted concurrent-renewal quest, which measured this against #5148's earlier serial refresh.) -- PUBLISH_NAMESPACE renewal comes first. Per-SUBSCRIBE tokens follow the same - shape but have no consumer yet; include them only if they fall out of the - same code. - On moq-transport the update carries the token as USE_VALUE and needs only step 1, so the PUBLISH_NAMESPACE consumer doesn't wait on the lite wire. On lite it names the auth registered through [Requests name their auth](/quest/m1/auth/request-auth-id.md), so the lite path follows that quest. A refused update ends only that request. -- Mirror `Auth`, `Route::auth` and `Subscription::auth` in `js/net` and the - bindings. -- Open (2026-10-16): a `Route` lives on an origin shared by every session it - feeds, including a reconnecting connection's later sessions, while `add()` - belongs to one session's handle. Which sessions may present a route's - `Auth` is unsettled; see #5695. +- Mirror `Auth` and `Route::auth` in `js/net`; the bindings expose no auth + yet, so [Bindings](/quest/m1/auth/bindings.md) carries them. - Describe request renewal in `doc/concept/moq-lite.md`'s Authorization section, and in `doc/concept/standard.md`, which describes only incoming request tokens; the moq-transport path can land before the lite one. From d0d9335cade12666d4605d1797ee8fb422298aea Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 16 Oct 2026 09:55:52 -0700 Subject: [PATCH 04/14] feat(net)!: a route carries an auth and renews its announce on a swap auth::Handle::add() returns a cloneable auth::Auth, presented until its last clone drops. Route::auth names one; the ietf publisher sends it as the PUBLISH_NAMESPACE authorization token, renews with REQUEST_UPDATE on draft-17+ and restarts the request before. Renewals are in flight together. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/auth.rs | 234 ++++++++++--- rs/moq-net/src/client.rs | 4 +- rs/moq-net/src/ietf/control.rs | 33 +- rs/moq-net/src/ietf/publisher.rs | 563 ++++++++++++++++++++++++++---- rs/moq-net/src/ietf/session.rs | 20 +- rs/moq-net/src/ietf/subscriber.rs | 4 +- rs/moq-net/src/lite/session.rs | 2 +- rs/moq-net/src/model/origin.rs | 58 ++- rs/moq-net/src/server.rs | 12 +- rs/moq-net/tests/auth.rs | 80 ++++- rs/moq-net/tests/extensions.rs | 17 +- 11 files changed, 864 insertions(+), 163 deletions(-) diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index 207632e0b8a..4ff74ee4d88 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -5,7 +5,9 @@ //! setup, and learns the [`Grant`] it earned. `Session::auth` //! returns the [`Handle`]: [`grant`](Handle::grant) is the union of every token //! this side presented, and `add` presents another without -//! reconnecting. Dropping the returned [`Token`] withdraws it. +//! reconnecting. The returned [`Auth`] is cloneable; dropping its last clone withdraws it. +//! A [`Route`](crate::origin::Route) can name it, so the request advertising the route +//! carries it too, and swapping it renews that request in place. //! //! The other direction, answering the peer's tokens, is automatic: the session //! grants what its own origin handles allow. An application that verifies tokens @@ -22,8 +24,9 @@ //! //! moq-transport draft-17+ carries the same exchange when both sides negotiate the //! MoQ Auth extension. Older versions, and peers that do not negotiate it, carry no -//! AUTH exchange: there the grant stays `None` and `add` fails with -//! [`Error::Unsupported`]. moq-lite carries a grant's patterns as they are; +//! AUTH exchange: there the grant stays `None`. On moq-transport `add` still succeeds, +//! with a grant that stays unknown, since a request that names the auth carries its +//! token; on moq-lite without AUTH it fails with [`Error::Unsupported`]. moq-lite carries a grant's patterns as they are; //! moq-transport carries namespace prefixes, so it refuses a grant that is not a //! union of subtrees rather than widen it. @@ -124,6 +127,9 @@ fn earliest(a: Option, b: Option) -> Option { pub(crate) struct State { /// Whether the negotiated version carries AUTH at all. supported: bool, + /// Whether a request can carry a token itself (moq-transport), so an auth needs no + /// AUTH stream to be named. + requests: bool, /// Set once the session ends; every waiter resolves with it. closed: Option, #[cfg_attr(not(feature = "async"), allow(dead_code))] @@ -163,12 +169,14 @@ pub(crate) struct Slot { setup: bool, /// The latest AUTH_OK, `None` before the first and after the token ends. grant: Option, + /// Handed to the driver to open its AUTH stream, which then owns ending it. + streamed: bool, /// The first reply: `Ok` for an AUTH_OK, the refusal otherwise. #[cfg_attr(not(feature = "async"), allow(dead_code))] answered: Option>, /// Why the token ended, once it has. ended: Option, - /// The [`Token`] handle dropped: the driver closes the stream. + /// The last [`Auth`] clone dropped: the driver closes the stream. withdrawn: bool, } @@ -320,6 +328,19 @@ impl Handle { } } + /// A moq-transport handle: like [`new`](Self::new), but a request can carry a token + /// itself, so [`add`](Self::add) succeeds without AUTH. + #[cfg_attr(not(feature = "async"), allow(dead_code))] + pub(crate) fn ietf(supported: bool) -> Self { + Self { + state: kio::Shared::new(State { + supported, + requests: true, + ..Default::default() + }), + } + } + /// Whether this side speaks AUTH: false on a version without it, on a side that does /// not offer it, and once the peer turns out not to. #[cfg_attr(not(feature = "async"), allow(dead_code))] @@ -336,25 +357,27 @@ impl Handle { /// Present another token, resolving once the peer answers it. /// - /// The grant joins the union for as long as the returned [`Token`] lives. + /// The grant joins the union while any clone of the returned [`Auth`] lives. On + /// moq-transport without AUTH there is no answer to wait for: it resolves at once with + /// a grant that stays unknown, and only a request that names it carries the token. /// /// # Errors /// - /// [`Error::Unsupported`] when the version or the peer carries no AUTH (or the - /// peer takes no tokens in band), the peer's refusal as - /// [`Error::Session`], or the session's close. + /// [`Error::Unsupported`] when neither the session nor its requests can carry a token + /// (moq-lite without AUTH, or a peer that takes no tokens in band), the peer's refusal + /// as [`Error::Session`], or the session's close. #[cfg(feature = "async")] - pub async fn add(&self, token: impl Into) -> Result { - let token = self.present(token.into(), false)?; - kio::wait(|waiter| token.poll_answered(waiter)).await?; - Ok(token) + pub async fn add(&self, token: impl Into) -> Result { + let auth = self.present(token.into(), false)?; + kio::wait(|waiter| auth.poll_answered(waiter)).await?; + Ok(auth) } /// Queue a token for the driver to present. #[cfg_attr(not(feature = "async"), allow(dead_code))] - pub(crate) fn present(&self, token: Bytes, setup: bool) -> Result { + pub(crate) fn present(&self, token: Bytes, setup: bool) -> Result { let mut state = self.state.lock(); - if !state.supported { + if !state.supported && !state.requests { return Err(Error::Unsupported); } if let Some(err) = state.closed() { @@ -362,24 +385,47 @@ impl Handle { } let id = state.next_id; state.next_id += 1; + // Without AUTH only a request presents it, so nothing will answer. + let answered = (!state.supported).then_some(Ok(())); state.tokens.insert( id, Slot { token, setup, grant: None, - answered: None, + streamed: false, + answered, ended: None, withdrawn: false, }, ); - state.opening.push_back(id); - Ok(Token { - state: self.state.clone(), - id, + if state.supported { + state.opening.push_back(id); + } + Ok(Auth { + presented: std::sync::Arc::new(Presented { + state: self.state.clone(), + id, + }), }) } + /// The token a request carries for `auth`, or `None` when the request goes out unnamed + /// under the session's scope: `auth` came from another handle, it ended, or requests + /// carry no token on this version. + #[cfg_attr(not(feature = "async"), allow(dead_code))] + pub(crate) fn token(&self, auth: &Auth) -> Option { + if !auth.presented.state.same_channel(&self.state) { + return None; + } + let state = self.state.read(); + if !state.requests { + return None; + } + let slot = state.tokens.get(&auth.presented.id)?; + slot.ended.is_none().then(|| slot.token.clone()) + } + /// Answer every token the peer presents, instead of the default acceptor: on its own /// AUTH stream, or riding one of its moq-transport SUBSCRIBE or PUBLISH_NAMESPACE /// requests as an `AUTHORIZATION TOKEN`. @@ -466,7 +512,10 @@ impl Handle { return Poll::Ready(None); } let id = state.opening.pop_front().expect("checked non-empty"); - let token = state.tokens.get(&id).map(|slot| slot.token.clone()); + let token = state.tokens.get_mut(&id).map(|slot| { + slot.streamed = true; + slot.token.clone() + }); match token { Some(token) => Poll::Ready(Some((id, token))), // Withdrawn before its stream opened: nothing to present. @@ -665,15 +714,21 @@ impl Handle { scope.is_none_or(|scope| scope.matches(path)) } - /// The peer turned out not to negotiate AUTH: fail every token we presented as - /// unsupported, leaving the union unknown. The peer's tokens can still ride its requests, - /// so their queue stays open. + /// The peer turned out not to negotiate AUTH, leaving the union unknown. On + /// moq-transport every token we presented stays, with an unknown grant, for the + /// requests that name it; elsewhere each fails as unsupported. The peer's tokens can + /// still ride its requests, so their queue stays open. #[cfg_attr(not(feature = "async"), allow(dead_code))] pub(crate) fn unsupported(&self) { let mut state = self.state.lock(); state.supported = false; state.opening.clear(); + let requests = state.requests; for slot in state.tokens.values_mut() { + if requests { + slot.answered.get_or_insert(Ok(())); + continue; + } slot.answered.get_or_insert(Err(Error::Unsupported)); slot.ended.get_or_insert(Error::Unsupported); } @@ -709,31 +764,42 @@ impl Handle { } } -/// A token this side presented. Dropping it withdraws the token, closing its -/// stream and removing its grant from the union. -pub struct Token { +/// One credential this side presented, returned by [`Handle::add`]. +/// +/// Cheap to clone, and every clone names the same credential. It stays presented +/// until the last clone drops, which closes its stream and removes its grant from +/// the union. A [`Route`](crate::origin::Route) holding one keeps it presented while +/// the route stands. +#[derive(Clone)] +pub struct Auth { + presented: std::sync::Arc, +} + +/// The slot every clone of one [`Auth`] shares, withdrawn when the last drops. +struct Presented { state: kio::Shared, id: u64, } -impl Token { - /// This token's own grant: `None` until the peer answers, and again once the - /// token ends. +impl Auth { + /// This credential's own grant: `None` until the peer answers, forever on + /// moq-transport without AUTH, and again once the credential ends. pub fn grant(&self) -> Watch { - Watch::new(self.state.clone(), Selector::Token(self.id)) + Watch::new(self.presented.state.clone(), Selector::Slot(self.presented.id)) } - /// Wait for the token to end: the peer's revocation as [`Error::Session`], + /// Wait for the credential to end: the peer's revocation as [`Error::Session`], /// [`Error::Cancel`] when the peer closed it without one, or the session's /// close. #[cfg(feature = "async")] pub async fn closed(&self) -> Error { + let Presented { state, id } = &*self.presented; kio::wait(|waiter| { - let state = ready_or!(self.state.poll(waiter, |state| match state.tokens.get(&self.id) { + let state = ready_or!(state.poll(waiter, |state| match state.tokens.get(id) { Some(slot) if slot.ended.is_none() => Poll::Pending, _ => Poll::Ready(()), })); - let err = state.tokens.get(&self.id).and_then(|slot| slot.ended.clone()); + let err = state.tokens.get(id).and_then(|slot| slot.ended.clone()); Poll::Ready(err.unwrap_or(Error::Cancel)) }) .await @@ -741,24 +807,40 @@ impl Token { #[cfg_attr(not(feature = "async"), allow(dead_code))] fn poll_answered(&self, waiter: &kio::Waiter) -> Poll> { - let state = ready_or!(self.state.poll(waiter, |state| match state.tokens.get(&self.id) { + let Presented { state, id } = &*self.presented; + let state = ready_or!(state.poll(waiter, |state| match state.tokens.get(id) { Some(slot) if slot.answered.is_none() => Poll::Pending, _ => Poll::Ready(()), })); - let answered = state.tokens.get(&self.id).and_then(|slot| slot.answered.clone()); + let answered = state.tokens.get(id).and_then(|slot| slot.answered.clone()); Poll::Ready(answered.unwrap_or(Err(Error::Cancel))) } } -impl Drop for Token { +impl PartialEq for Auth { + fn eq(&self, other: &Self) -> bool { + std::sync::Arc::ptr_eq(&self.presented, &other.presented) + } +} + +impl Eq for Auth {} + +impl std::fmt::Debug for Auth { + // Never the token: routes are logged, and a credential must not be. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_tuple("Auth").field(&self.presented.id).finish() + } +} + +impl Drop for Presented { fn drop(&mut self) { let mut state = self.state.lock(); let Some(slot) = state.tokens.get_mut(&self.id) else { return; }; slot.withdrawn = true; - // Already over, or never reached the wire: nothing left to close. - if slot.ended.is_some() || state.opening.contains(&self.id) { + // Already over, or no stream whose close would end it: nothing left to close. + if slot.ended.is_some() || !slot.streamed { state.opening.retain(|id| *id != self.id); state.tokens.remove(&self.id); state.recompute(); @@ -768,12 +850,12 @@ impl Drop for Token { enum Selector { Union, - Token(u64), + Slot(u64), } -/// Watches a grant: the session's union, or one token's. +/// Watches a grant: the session's union, or one credential's. /// -/// Created by [`Handle::grant`] and [`Token::grant`]. +/// Created by [`Handle::grant`] and [`Auth::grant`]. pub struct Watch { state: kio::Shared, selector: Selector, @@ -794,7 +876,7 @@ impl Watch { fn current(&self, state: &State) -> Option { match self.selector { Selector::Union => state.union.clone(), - Selector::Token(id) => state.tokens.get(&id).and_then(|slot| slot.grant.clone()), + Selector::Slot(id) => state.tokens.get(&id).and_then(|slot| slot.grant.clone()), } } @@ -805,7 +887,7 @@ impl Watch { } match self.selector { Selector::Union => None, - Selector::Token(id) => match state.tokens.get(&id) { + Selector::Slot(id) => match state.tokens.get(&id) { Some(slot) => slot.ended.clone(), None => Some(Error::Cancel), }, @@ -828,12 +910,12 @@ impl Watch { let state = ready_or!(self.state.poll(waiter, |state| { let current = match selector { Selector::Union => state.union.as_ref(), - Selector::Token(id) => state.tokens.get(id).and_then(|slot| slot.grant.as_ref()), + Selector::Slot(id) => state.tokens.get(id).and_then(|slot| slot.grant.as_ref()), }; let ended = state.closed.is_some() || match selector { Selector::Union => false, - Selector::Token(id) => state.tokens.get(id).is_none_or(|slot| slot.ended.is_some()), + Selector::Slot(id) => state.tokens.get(id).is_none_or(|slot| slot.ended.is_some()), }; match current != last.as_ref() || ended { true => Poll::Ready(()), @@ -1847,6 +1929,68 @@ mod tests { handle.authorize(&grant(&[""], &[""])); assert!(default.poll(&waiter).is_pending()); } + + /// On moq-transport without AUTH an auth needs no answer: it is kept for the requests + /// that name it, with a grant that stays unknown. moq-lite without AUTH has nothing that + /// could carry it. + #[test] + fn a_request_carries_an_auth_without_the_auth_stream() { + let ietf = Handle::ietf(false); + let auth = ietf.present(Bytes::from_static(b"jwt"), false).unwrap(); + assert!(matches!(auth.poll_answered(&kio::Waiter::noop()), Poll::Ready(Ok(())))); + assert_eq!(auth.grant().peek(), None); + assert_eq!(ietf.token(&auth).as_deref(), Some(b"jwt".as_slice())); + assert!(ietf.state.read().opening.is_empty(), "nothing to open a stream for"); + + let lite = Handle::new(false); + assert!(matches!(lite.present(Bytes::from_static(b"jwt"), false), Err(Error::Unsupported))); + } + + /// A peer that turns out not to speak AUTH leaves a moq-transport auth for its + /// requests rather than failing it. + #[test] + fn an_unsupported_peer_keeps_a_request_auth() { + let handle = Handle::ietf(true); + let auth = handle.present(Bytes::from_static(b"jwt"), false).unwrap(); + assert!(auth.poll_answered(&kio::Waiter::noop()).is_pending()); + handle.unsupported(); + assert!(matches!(auth.poll_answered(&kio::Waiter::noop()), Poll::Ready(Ok(())))); + assert_eq!(handle.token(&auth).as_deref(), Some(b"jwt".as_slice())); + } + + /// Only the handle that issued an auth carries it: a route naming another session's + /// auth goes out under this session's scope. + #[test] + fn another_handle_does_not_carry_an_auth() { + let issuer = Handle::ietf(false); + let auth = issuer.present(Bytes::from_static(b"jwt"), false).unwrap(); + assert_eq!(Handle::ietf(false).token(&auth), None); + } + + /// Every clone names one credential, which stays presented until the last drops. + #[test] + fn the_last_clone_withdraws_the_auth() { + let handle = Handle::new(true); + let auth = handle.present(Bytes::from_static(b"jwt"), false).unwrap(); + let Poll::Ready(Some((id, _))) = handle.poll_opening(&kio::Waiter::noop()) else { + panic!("nothing to open"); + }; + let clone = auth.clone(); + assert_eq!(clone, auth, "a clone is the same auth"); + assert_ne!( + handle.present(Bytes::from_static(b"jwt"), false).unwrap(), + auth, + "the same bytes presented again are another auth" + ); + + drop(auth); + assert!( + handle.poll_withdrawn(id, &kio::Waiter::noop()).is_pending(), + "withdrawn while a clone lives" + ); + drop(clone); + assert!(handle.poll_withdrawn(id, &kio::Waiter::noop()).is_ready()); + } } #[cfg(all(test, feature = "async"))] diff --git a/rs/moq-net/src/client.rs b/rs/moq-net/src/client.rs index 395fde91f38..58c66ac41d0 100644 --- a/rs/moq-net/src/client.rs +++ b/rs/moq-net/src/client.rs @@ -290,7 +290,7 @@ impl Client { // We advertise the request path in our SETUP for URL-less transports. // The peer's SETUP decides whether AUTH is negotiated. A handle that does not // offer it keeps the option out of our SETUP and presents no credential. - let auth = crate::auth::Handle::new(self.extensions.auth); + let auth = crate::auth::Handle::ietf(self.extensions.auth); let (protocol, goaway) = ietf::start(ietf::Config { runtime: runtime.clone(), limits: self.limits, @@ -461,7 +461,7 @@ impl Client { let stream = stream.with_version(v); // Draft 14-16 carry no AUTH, but the session is still limited through this handle. - let auth = crate::auth::Handle::new(false); + let auth = crate::auth::Handle::ietf(false); // Draft 14-16: the path rode in the bidi SETUP above, not the uni one. let (protocol, goaway) = ietf::start(ietf::Config { runtime: runtime.clone(), diff --git a/rs/moq-net/src/ietf/control.rs b/rs/moq-net/src/ietf/control.rs index 18ed656933c..cacf21096f0 100644 --- a/rs/moq-net/src/ietf/control.rs +++ b/rs/moq-net/src/ietf/control.rs @@ -241,6 +241,23 @@ impl Control { RequestSend(self.clone()) } + /// Allocate the next request_id once credit and the committed header allow it, without + /// giving up: for a caller that waits on credit beside its other work. + pub fn poll_next_request_id(&self, waiter: &kio::Waiter) -> Poll { + let allowed = self.state.poll(waiter, |state| { + let allowed = match state.request_id_max { + None => true, + Some(max) => state.request_id_next < max, + }; + match allowed && state.request_id_sending == 0 { + true => Poll::Ready(()), + false => Poll::Pending, + } + }); + let mut state = std::task::ready!(allowed); + Poll::Ready(state.request_id_next.increment()) + } + /// Allocate the next request_id once credit and the committed header allow it. /// /// `runtime` arms the give-up timer, so the wait cannot outlive the caller's @@ -249,20 +266,8 @@ impl Control { let mut timeout = crate::time::Deadline::after(runtime, std::time::Duration::from_secs(10)); kio::wait(|waiter| { - let allowed = self.state.poll(waiter, |state| { - let allowed = match state.request_id_max { - None => true, - Some(max) => state.request_id_next < max, - }; - if allowed && state.request_id_sending == 0 { - Poll::Ready(()) - } else { - Poll::Pending - } - }); - - if let Poll::Ready(mut state) = allowed { - return Poll::Ready(Ok(state.request_id_next.increment())); + if let Poll::Ready(id) = self.poll_next_request_id(waiter) { + return Poll::Ready(Ok(id)); } if timeout.poll(waiter).is_ready() { diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index ec5fef0dc40..1c4875662a4 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -1,7 +1,7 @@ use crate::{frame, group, origin, track}; use futures::FutureExt as _; use std::{ - collections::HashMap, + collections::{HashMap, HashSet, VecDeque}, ops::Bound, sync::{ Arc, @@ -413,6 +413,12 @@ struct Namespaces { /// The open PUBLISH_NAMESPACE request carrying each advertised namespace. Empty when /// the entries ride a SUBSCRIBE_NAMESPACE stream inline. requests: HashMap>, + /// Requests with an update to send, in the order they asked for a request ID. Each + /// waits for credit beside the loop's other work and computes its update when it gets + /// one, so a later change supersedes an earlier one that is still waiting. + updates: VecDeque, + /// Requests with an update in flight, each answered on its own stream. + inflight: HashSet, /// What we may advertise: our grant (MoQ Auth) and the ceiling on the peer. permit: crate::auth::Permit, /// The auth epoch last applied to `permit`. @@ -428,6 +434,8 @@ impl Namespaces { target, watched: HashMap::new(), requests: HashMap::new(), + updates: VecDeque::new(), + inflight: HashSet::new(), permit: Default::default(), epoch: 0, hidden: false, @@ -489,6 +497,10 @@ enum NamespaceEvent { Retry, /// Our grant (MoQ Auth) or the ceiling changed: re-check every namespace against it. Regrant(crate::auth::Permit), + /// The queued update at the front got this request ID. + Renew(RequestId), + /// An update in flight was answered, or `None` once its deadline passed. + Answered(crate::PathOwned, Option>), } #[derive(Clone)] @@ -2705,27 +2717,45 @@ where suffix: &crate::PathOwned, path: &crate::PathOwned, ) -> Result<(), Error> { - let permitted = ns.permitted(path); + let Some((advert, auth)) = self.offer(ns, suffix, path) else { + return Ok(()); + }; let Namespaces { - peer, target, watched, requests, + updates, .. } = ns; - let Some(watch) = watched.get(suffix) else { return Ok(()); }; - // Nothing our grant does not cover reaches the wire, and a shrink withdraws it. - let advert = match permitted { - true => self.select(&watch.route, peer), - false => Advert::None, - }; let refused = watch.refused; let wanted = advert.wanted(); let held = watch.sent.wanted(); - let unchanged = advert == watch.sent; + let request = requests.get_mut(suffix); + let held_auth = request.as_ref().and_then(|request| request.auth.clone()); + let unchanged = advert == watch.sent && auth == held_auth; + // moq-transport renames a request's auth with REQUEST_UPDATE, but cannot name one on + // a request that had none, nor drop one, and before draft-17 cannot update a + // namespace at all: those withdraw the request and make it again. + let restart = held_auth != auth + && (held_auth.is_none() + || auth.is_none() + || matches!(self.version, Version::Draft14 | Version::Draft15 | Version::Draft16)); + + // An update already waiting or in flight carries whatever is current once it goes + // out, and its answer brings the loop back here for anything after that. + if wanted + && !restart && let Some(request) = request + && !unchanged + { + if !request.queued && request.pending.is_none() { + request.queued = true; + updates.push_back(suffix.clone()); + } + return Ok(()); + } if unchanged { // Nothing to send. A namespace that is no longer advertisable is no longer @@ -2757,15 +2787,16 @@ where self.withdraw_namespace(target, requests, suffix.clone()).await?; } (true, Some(_)) => { - tracing::debug!(broadcast = %absolute, "publish_namespace update"); + tracing::debug!(broadcast = %absolute, "publish_namespace restart for its auth"); + self.withdraw_namespace(target, requests, suffix.clone()).await?; refused = self - .update_namespace(target, requests, suffix, &watch.sent, &advert) + .advertise_namespace(requests, path, suffix.clone(), advert.params(), auth) .await?; } (true, None) => { tracing::debug!(broadcast = %absolute, "publish_namespace"); refused = self - .advertise_namespace(requests, path, suffix.clone(), advert.params()) + .advertise_namespace(requests, path, suffix.clone(), advert.params(), auth) .await?; } } @@ -2828,6 +2859,7 @@ where path: &crate::PathOwned, suffix: crate::PathOwned, cluster: Option, + auth: Option, ) -> Result { let request_id = self.control.next_request_id(&self.runtime).await?; @@ -2847,7 +2879,7 @@ where request_id, track_namespace: path.as_path(), cluster, - authorization_token: None, + authorization_token: auth.as_ref().and_then(|auth| self.request_token(auth)), }) .await?; @@ -2898,81 +2930,209 @@ where path: path.clone(), request_id, stream: request, + auth, + queued: false, + pending: None, }, ); Ok(Refused::No) } - /// Update a namespace the peer holds: REQUEST_UPDATE on the request that carries - /// it, with only the parameters that changed, then its answer. - /// - /// Waiting for the answer keeps one update outstanding per stream, which satisfies - /// any MAX_REQUEST_UPDATES the peer set without reading it, and the answer is what - /// decides whether the peer still holds the namespace: a REQUEST_ERROR closes the - /// stream and withdraws the advertisement (moq-transport Section 9.5.1), so the - /// request is dropped here and the retry re-offers it fresh. An unanswered update - /// is dropped the same way, since a peer that ignored it cannot be assumed to hold - /// either price. - /// - /// A different original publisher is an update like any other: the receiver - /// drains what it already serves from the old one and never splices the two. + /// What the peer should hold for one namespace: the advertisement, and the auth its + /// PUBLISH_NAMESPACE request carries. `None` when the namespace is not watched. /// - /// Returns what the refusal, if any, said about coming back. - async fn update_namespace( + /// Nothing our grant does not cover reaches the wire, and a shrink withdraws it. Only a + /// request carries an auth, and only one this session's handle issued: any other route + /// is advertised under the session's scope. + fn offer( &self, - target: &mut Target, - requests: &mut HashMap>, + ns: &Namespaces, suffix: &crate::PathOwned, - held: &Advert, - advert: &Advert, - ) -> Result { - // A plain advertisement has no parameters to reprice, and a namespace the peer - // does not hold has nothing to update; neither is a wire message. - let (Some(next), Some(held)) = (advert.params(), held.params()) else { - return Ok(Refused::No); + path: &crate::PathOwned, + ) -> Option<(Advert, Option)> { + let watch = ns.watched.get(suffix)?; + let advert = match ns.permitted(path) { + true => self.select(&watch.route, &ns.peer), + false => Advert::None, }; - let Some(request) = requests.get_mut(suffix) else { - return Ok(Refused::No); + let auth = match (&ns.target, advert.wanted()) { + (Target::Requests(_), true) => watch + .route + .auth + .as_ref() + .filter(|auth| self.auth.token(auth).is_some()) + .cloned(), + _ => None, }; - let request_id = self.control.next_request_id(&self.runtime).await?; - let update = ietf::PublishNamespaceUpdate::between(request_id, &held, &next); + Some((advert, auth)) + } + + /// The `AUTHORIZATION TOKEN` a request names `auth` with, by value. + fn request_token(&self, auth: &crate::auth::Auth) -> Option { + let token = self.auth.token(auth)?; + Some(super::token::RequestToken(crate::setup::Token { + kind: crate::setup::Token::OUT_OF_BAND, + value: token.to_vec(), + })) + } + + /// Drop queued updates that no longer have anything to send, so the next one to take + /// a request ID is one that will use it: drafts before 17 require the next ID. + fn prune_updates(&self, ns: &mut Namespaces, prefix: &crate::PathOwned) { + while let Some(suffix) = ns.updates.front() { + let wants = ns.requests.get(suffix).is_some_and(|request| { + request.queued + && request.pending.is_none() + && ns.watched.get(suffix).is_some_and(|watch| { + self.offer(ns, suffix, &prefix.join(suffix)) + .is_some_and(|(advert, auth)| advert.wanted() && (advert != watch.sent || auth != request.auth)) + }) + }); + if wants { + return; + } + let suffix = ns.updates.pop_front().expect("checked non-empty"); + if let Some(request) = ns.requests.get_mut(&suffix) { + request.queued = false; + } + } + } + /// Send the update waiting at the front of the queue with `request_id`: REQUEST_UPDATE + /// on the request that carries the namespace, with only what changed since the peer's + /// copy. Its answer arrives through [`Self::poll_answer`] while the loop carries on. + /// + /// One update is outstanding per request, which satisfies any MAX_REQUEST_UPDATES the + /// peer set without reading it. A different original publisher is an update like any + /// other: the receiver drains what it already serves from the old one and never splices + /// the two. + async fn send_update( + &self, + ns: &mut Namespaces, + prefix: &crate::PathOwned, + request_id: RequestId, + ) -> Result<(), Error> { + let suffix = ns.updates.pop_front().expect("allocated for a queued update"); + let Some((advert, auth)) = self.offer(ns, &suffix, &prefix.join(&suffix)) else { + return Ok(()); + }; + let (Some(request), Some(watch)) = (ns.requests.get_mut(&suffix), ns.watched.get_mut(&suffix)) else { + return Ok(()); + }; + request.queued = false; + let mut update = match (advert.params(), watch.sent.params()) { + (Some(next), Some(held)) => ietf::PublishNamespaceUpdate::between(request_id, &held, &next), + _ => ietf::PublishNamespaceUpdate { + request_id, + namespace_request_id: None, + hops: None, + cost: None, + authorization_token: None, + }, + }; + if auth != request.auth { + update.authorization_token = auth.as_ref().and_then(|auth| self.request_token(auth)); + } + tracing::debug!(broadcast = %self.origin.absolute(&request.path), "publish_namespace update"); request.stream.writer.varint(ietf::PublishNamespaceUpdate::ID).await?; request.stream.writer.encode(&update).await?; - let absolute = self.origin.absolute(&request.path).to_owned(); - let Some((type_id, body)) = self.read_response(&mut request.stream).await? else { - tracing::debug!(broadcast = %absolute, "no answer to the update"); - // Abrupt: a peer that never answers is not owed the FIN handshake. - requests.remove(suffix); - return Ok(Refused::No); + // The peer holds the update unless its answer says otherwise. + request.pending = Some(self.runtime.now() + ADVERTISE_TIMEOUT); + request.auth = auth; + watch.sent = advert; + ns.inflight.insert(suffix); + Ok(()) + } + + /// The answer to an update in flight, or `None` for one past its deadline. + fn poll_answer( + requests: &mut HashMap>, + inflight: &HashSet, + now: crate::time::Instant, + cx: &mut std::task::Context<'_>, + ) -> Poll<(crate::PathOwned, Option>)> { + for suffix in inflight { + let Some(request) = requests.get_mut(suffix) else { + continue; + }; + let Some(deadline) = request.pending else { + continue; + }; + if let Poll::Ready(answer) = request.stream.reader.poll_decode(cx) { + return Poll::Ready((suffix.clone(), Some(answer))); + } + if now >= deadline { + return Poll::Ready((suffix.clone(), None)); + } + } + Poll::Pending + } + + /// Apply the answer to an update: the peer still holds the namespace on an OK, and a + /// REQUEST_ERROR closed the stream and withdrew the advertisement (moq-transport Section + /// 9.5.1), so the request is dropped and the retry re-offers it fresh. An unanswered + /// update is dropped the same way, since a peer that ignored it cannot be assumed to hold + /// either version. + async fn answered( + &self, + ns: &mut Namespaces, + prefix: &crate::PathOwned, + suffix: crate::PathOwned, + answer: Option>, + ) -> Result<(), Error> { + ns.inflight.remove(&suffix); + let Some(request) = ns.requests.get_mut(&suffix) else { + return Ok(()); }; - let mut data = body.decoder(self.version); + request.pending = None; + let absolute = self.origin.absolute(&request.path).to_owned(); - match type_id { - ietf::RequestOk::ID => { - let msg = ietf::RequestOk::decode_msg(&mut data, self.version)?; - // ACTIVE_COUNT only answers SUBSCRIBE_NAMESPACE (MoQ Active Count); closed - // here for the same reason as in `advertise_namespace`. - if msg.active.is_some() { - self.session.clone().close( - super::error::to_session_code(&crate::SessionError::ProtocolViolation), - "ACTIVE_COUNT on a REQUEST_UPDATE answer", - ); - return Err(Error::ProtocolViolation); + let refused = match answer { + None => { + tracing::debug!(broadcast = %absolute, "no answer to the update"); + // Abrupt: a peer that never answers is not owed the FIN handshake. + ns.requests.remove(&suffix); + Refused::No + } + Some(answer) => { + let super::request_stream::Framed(type_id, body) = answer?; + let mut data = body.decoder(self.version); + match type_id { + ietf::RequestOk::ID => { + let msg = ietf::RequestOk::decode_msg(&mut data, self.version)?; + // ACTIVE_COUNT only answers SUBSCRIBE_NAMESPACE (MoQ Active Count); + // closed here for the same reason as in `advertise_namespace`. + if msg.active.is_some() { + self.session.clone().close( + super::error::to_session_code(&crate::SessionError::ProtocolViolation), + "ACTIVE_COUNT on a REQUEST_UPDATE answer", + ); + return Err(Error::ProtocolViolation); + } + tracing::debug!(message = ?msg, "publish_namespace update ok"); + // Whatever changed while it was in flight goes out next. + return self.sync_namespace(ns, &suffix, &prefix.join(&suffix)).await; + } + ietf::RequestError::ID => { + let msg = ietf::RequestError::decode_msg(&mut data, self.version)?; + tracing::warn!(message = ?msg, "publish_namespace update error"); + // The peer closed its side; closing ours completes the withdrawal. + self.withdraw_namespace(&mut ns.target, &mut ns.requests, suffix.clone()) + .await?; + self.refusal(msg.retry_interval) + } + _ => return Err(Error::UnexpectedMessage), } - tracing::debug!(message = ?msg, "publish_namespace update ok"); - Ok(Refused::No) - } - ietf::RequestError::ID => { - let msg = ietf::RequestError::decode_msg(&mut data, self.version)?; - tracing::warn!(message = ?msg, "publish_namespace update error"); - // The peer closed its side; closing ours completes the withdrawal. - self.withdraw_namespace(target, requests, suffix.clone()).await?; - Ok(self.refusal(msg.retry_interval)) } - _ => Err(Error::UnexpectedMessage), + }; + + if let Some(watch) = ns.watched.get_mut(&suffix) { + watch.refused = refused; + watch.deferred = refused.pending(); + watch.sent = Advert::None; } + Ok(()) } /// How to read a refusal's retry interval, in milliseconds. @@ -3327,6 +3487,8 @@ where // the same tick. let mut retry = crate::time::Deadline::new(&self.runtime); let mut retry_at: Option = None; + // When the earliest update in flight gives up on its answer. + let mut answer_by = crate::time::Deadline::new(&self.runtime); let mut retry_delay = RETRY_BASE; // Stream updates (origin route (un)announces), bailing if the peer closes @@ -3342,9 +3504,21 @@ where } } retry.set(retry_at); + // Requests an update was withdrawn from leave the in-flight set here. + let Namespaces { requests, inflight, .. } = &mut ns; + inflight.retain(|suffix| requests.get(suffix).is_some_and(|request| request.pending.is_some())); + answer_by.set(inflight.iter().filter_map(|suffix| requests[suffix].pending).min()); + self.prune_updates(&mut ns, &prefix); let event = { - let Namespaces { target, epoch, .. } = &mut ns; + let Namespaces { + target, + epoch, + requests, + inflight, + updates, + .. + } = &mut ns; kio::wait(|waiter| { if self.withdrawal.poll(waiter).is_ready() { return Poll::Ready(NamespaceEvent::Update(None)); @@ -3358,6 +3532,19 @@ where if let Poll::Ready(event) = target.poll_event(&mut finished, self.version, &mut cx) { return Poll::Ready(event); } + // Each update in flight is answered on its own stream, so many renew at + // once and none holds up the announcements behind it. + let _ = answer_by.poll(waiter); + let now = self.runtime.now(); + if let Poll::Ready((suffix, answer)) = Self::poll_answer(requests, inflight, now, &mut cx) { + return Poll::Ready(NamespaceEvent::Answered(suffix, answer)); + } + // Credit goes to queued updates in order, as the drafts before 17 require. + if !updates.is_empty() + && let Poll::Ready(id) = self.control.poll_next_request_id(waiter) + { + return Poll::Ready(NamespaceEvent::Renew(id)); + } if let Poll::Ready(next) = announced.poll_next(waiter) { return Poll::Ready(NamespaceEvent::Update(next.map(|event| match event { crate::announce::Event::Start(update) | crate::announce::Event::Update(update) => { @@ -3481,6 +3668,8 @@ where self.sync_namespace(&mut ns, &suffix, &path).await?; } } + NamespaceEvent::Renew(request_id) => self.send_update(&mut ns, &prefix, request_id).await?, + NamespaceEvent::Answered(suffix, answer) => self.answered(&mut ns, &prefix, suffix, answer).await?, NamespaceEvent::Update(None) => { // The origin is gone: withdraw everything, then finish the // stream and wait for delivery. @@ -4532,6 +4721,13 @@ struct NamespaceRequest { path: crate::PathOwned, request_id: RequestId, stream: Stream, + /// The auth the peer holds the request under: the one it opened with, or the one + /// its last update carried. Held, so the credential stays presented while it does. + auth: Option, + /// Waiting in [`Namespaces::updates`] for a request ID. + queued: bool, + /// When to give up on the update in flight, which has no answer yet. + pending: Option, } #[cfg(test)] @@ -9576,8 +9772,8 @@ mod serve_tests { /// An auth handle whose session grant covers only `other`, so a SUBSCRIBE for `room` /// falls to its request token. The presented credential is kept alive by the returned - /// [`crate::auth::Token`]. - fn auth_covering_other() -> (crate::auth::Handle, crate::auth::Requests, crate::auth::Token) { + /// [`crate::auth::Auth`]. + fn auth_covering_other() -> (crate::auth::Handle, crate::auth::Requests, crate::auth::Auth) { let auth = crate::auth::Handle::new(true); let requests = auth.requests().unwrap(); let cred = auth.present(bytes::Bytes::from_static(b"cred"), true).unwrap(); @@ -12613,6 +12809,227 @@ mod tests { assert_eq!(log.bi_opens(), 1, "no withdrawal and no second stream"); } + /// The token a request carries for `value`, as the publisher writes it. + fn auth_token(value: &[u8]) -> super::super::token::RequestToken { + super::super::token::RequestToken(crate::setup::Token { + kind: crate::setup::Token::OUT_OF_BAND, + value: value.to_vec(), + }) + } + + /// A publisher on `VERSION` whose auth handle carries tokens on requests, advertising + /// `origin` unsolicited, with one script per stream it opens. + fn auth_publisher( + origin: &crate::origin::Producer, + auth: &crate::auth::Handle, + scripts: Vec>, + version: Version, + ) -> (Publisher, crate::lite::test_transport::Log) { + let session = crate::lite::test_transport::ScriptedSession::per_stream(scripts); + let log = session.log.clone(); + let publisher = Publisher::new( + crate::time::Clock::sim(), + session, + origin.consume(), + Control::new(None, false), + None, + declared(Some(false)), + version, + ) + .with_auth(auth.clone()); + (publisher, log) + } + + /// An origin that forwards route updates at once: the hold is not what these check. + fn unheld_origin() -> crate::origin::Producer { + crate::origin::Config { + update_hold: Duration::ZERO, + ..crate::origin::Config::new(crate::Hop::new(1).unwrap()) + } + .produce() + } + + /// Poll the loop until `needle` shows up `count` times, or give up after 100 settles. + async fn poll_until( + run: &mut std::pin::Pin<&mut F>, + log: &crate::lite::test_transport::Log, + needle: &[u8], + count: usize, + ) { + for _ in 0..100 { + assert!(futures::poll!(run.as_mut()).is_pending()); + if occurrences(log, needle) >= count { + return; + } + settle().await; + } + } + + /// A route's auth rides its PUBLISH_NAMESPACE as the request's token, and swapping it + /// renews the request in place: REQUEST_UPDATE on the same stream, carrying only the + /// new token. + #[moq_net_sim::test] + async fn swapping_a_route_auth_is_a_request_update() { + const VERSION: Version = Version::Draft19; + + let origin = unheld_origin(); + let auth = crate::auth::Handle::ietf(false); + let first = auth.present(bytes::Bytes::from_static(b"first-secret"), false).unwrap(); + let second = auth.present(bytes::Bytes::from_static(b"second-secret"), false).unwrap(); + let cam = origin + .announce("cam", crate::origin::Route::default().with_auth(first)) + .unwrap(); + settle().await; + + let ok = publish_namespace_ok(VERSION).await; + let (publisher, log) = auth_publisher(&origin, &auth, vec![[ok.clone(), ok].concat()], VERSION); + let mut run = std::pin::pin!(publisher.run_publish_namespaces()); + poll_until(&mut run, &log, b"cam", 1).await; + assert_eq!(occurrences(&log, b"cam"), 1, "the advertisement never went out"); + assert_eq!(occurrences(&log, b"first-secret"), 1, "PUBLISH_NAMESPACE carries the auth"); + + cam.update(crate::origin::Route::default().with_auth(second)).unwrap(); + let expected = request_update( + VERSION, + &ietf::PublishNamespaceUpdate { + request_id: RequestId(3), + namespace_request_id: None, + hops: None, + cost: None, + authorization_token: Some(auth_token(b"second-secret")), + }, + ) + .await; + poll_until(&mut run, &log, &expected, 1).await; + + assert_eq!(occurrences(&log, &expected), 1, "REQUEST_UPDATE with the new token"); + assert_eq!(occurrences(&log, b"cam"), 1, "PUBLISH_NAMESPACE was not repeated"); + assert_eq!(log.bi_opens(), 1, "the update rode the request's own stream"); + } + + /// An auth another handle issued is not this session's to present: the route is + /// advertised under the session's scope, and the credential never reaches this peer. + #[moq_net_sim::test] + async fn a_foreign_route_auth_is_not_carried() { + const VERSION: Version = Version::Draft19; + + let origin = unheld_origin(); + let other = crate::auth::Handle::ietf(false); + let foreign = other.present(bytes::Bytes::from_static(b"foreign-secret"), false).unwrap(); + let _cam = origin + .announce("cam", crate::origin::Route::default().with_auth(foreign)) + .unwrap(); + settle().await; + + let ok = publish_namespace_ok(VERSION).await; + let (publisher, log) = auth_publisher(&origin, &crate::auth::Handle::ietf(false), vec![ok], VERSION); + let mut run = std::pin::pin!(publisher.run_publish_namespaces()); + poll_until(&mut run, &log, b"cam", 1).await; + + assert_eq!(occurrences(&log, b"cam"), 1, "the advertisement never went out"); + assert_eq!(occurrences(&log, b"foreign-secret"), 0, "sent another handle's credential"); + } + + /// moq-transport cannot name an auth on a request that had none, so naming one + /// withdraws the request and makes it again with the token. + #[moq_net_sim::test] + async fn naming_an_auth_restarts_an_unnamed_request() { + const VERSION: Version = Version::Draft19; + + let origin = unheld_origin(); + let auth = crate::auth::Handle::ietf(false); + let token = auth.present(bytes::Bytes::from_static(b"late-secret"), false).unwrap(); + let cam = origin.announce("cam", crate::origin::Route::default()).unwrap(); + settle().await; + + let ok = publish_namespace_ok(VERSION).await; + let (publisher, log) = auth_publisher(&origin, &auth, vec![ok.clone(), ok], VERSION); + let mut run = std::pin::pin!(publisher.run_publish_namespaces()); + poll_until(&mut run, &log, b"cam", 1).await; + assert_eq!(occurrences(&log, b"late-secret"), 0, "named before it had an auth"); + + cam.update(crate::origin::Route::default().with_auth(token)).unwrap(); + poll_until(&mut run, &log, b"cam", 2).await; + + assert_eq!(occurrences(&log, b"cam"), 2, "the request was not made again"); + assert_eq!(occurrences(&log, b"late-secret"), 1, "the new request carries the auth"); + assert_eq!(log.bi_opens(), 2, "a fresh stream for the fresh request"); + } + + /// Before draft-17 a namespace cannot be updated, so a renewal withdraws the request + /// and makes it again with the new token. + #[moq_net_sim::test] + async fn a_draft_14_renewal_restarts_the_request() { + const VERSION: Version = Version::Draft14; + + let origin = unheld_origin(); + let auth = crate::auth::Handle::ietf(false); + let first = auth.present(bytes::Bytes::from_static(b"first-secret"), false).unwrap(); + let second = auth.present(bytes::Bytes::from_static(b"second-secret"), false).unwrap(); + let cam = origin + .announce("cam", crate::origin::Route::default().with_auth(first)) + .unwrap(); + settle().await; + + let ok = publish_namespace_ok(VERSION).await; + let (publisher, log) = auth_publisher(&origin, &auth, vec![ok.clone(), ok], VERSION); + let mut run = std::pin::pin!(publisher.run_publish_namespaces()); + poll_until(&mut run, &log, b"first-secret", 1).await; + + cam.update(crate::origin::Route::default().with_auth(second)).unwrap(); + poll_until(&mut run, &log, b"second-secret", 1).await; + + assert_eq!(occurrences(&log, b"second-secret"), 1, "the renewal never went out"); + assert_eq!(occurrences(&log, b"first-secret"), 1, "the first token was not repeated"); + assert_eq!(log.bi_opens(), 2, "a fresh request for the renewal"); + } + + /// Renewals wait on their answers side by side: one the peer has not answered holds up + /// neither another namespace's renewal nor a new announcement. + #[moq_net_sim::test] + async fn renewals_are_in_flight_together() { + const VERSION: Version = Version::Draft19; + + let origin = unheld_origin(); + let auth = crate::auth::Handle::ietf(false); + let present = |token: &'static [u8]| auth.present(bytes::Bytes::from_static(token), false).unwrap(); + let a = origin + .announce("cam-a", crate::origin::Route::default().with_auth(present(b"a-first"))) + .unwrap(); + let b = origin + .announce("cam-b", crate::origin::Route::default().with_auth(present(b"b-first"))) + .unwrap(); + settle().await; + + // Each advertisement is answered and its update never is. + let ok = publish_namespace_ok(VERSION).await; + let (publisher, log) = auth_publisher(&origin, &auth, vec![ok.clone(), ok.clone(), ok], VERSION); + let mut run = std::pin::pin!(publisher.run_publish_namespaces()); + poll_until(&mut run, &log, b"cam-b", 1).await; + assert_eq!(occurrences(&log, b"cam-a"), 1, "cam-a was never advertised"); + assert_eq!(occurrences(&log, b"cam-b"), 1, "cam-b was never advertised"); + + a.update(crate::origin::Route::default().with_auth(present(b"a-second"))) + .unwrap(); + b.update(crate::origin::Route::default().with_auth(present(b"b-second"))) + .unwrap(); + poll_until(&mut run, &log, b"b-second", 1).await; + assert_eq!(occurrences(&log, b"a-second"), 1, "cam-a's renewal never went out"); + assert_eq!( + occurrences(&log, b"b-second"), + 1, + "cam-b's renewal waited on cam-a's answer" + ); + + let _c = origin.announce("cam-c", crate::origin::Route::default()).unwrap(); + poll_until(&mut run, &log, b"cam-c", 1).await; + assert_eq!( + occurrences(&log, b"cam-c"), + 1, + "a new announcement waited on the renewals" + ); + } + /// moq-transport has no restart: another source winning a namespace without an epoch /// withdraws it and advertises it afresh, so the peer resubscribes. #[moq_net_sim::test] diff --git a/rs/moq-net/src/ietf/session.rs b/rs/moq-net/src/ietf/session.rs index af9a871587a..ef0dff112c0 100644 --- a/rs/moq-net/src/ietf/session.rs +++ b/rs/moq-net/src/ietf/session.rs @@ -147,8 +147,8 @@ where // Present the connection's own credential (the empty token) right away, so both // sides learn their grant without waiting on the app. Draft-17+ only; the peer's // SETUP then decides whether it is ever sent. - // A handle that already knows the peer declined (a gated server accept) refuses it. - let setup_token = match auth::supported(version) { + // A handle that already knows the peer declined (a gated server accept) skips it. + let setup_token = match auth::supported(version) && auth.supported() { true => auth.present(bytes::Bytes::new(), true).ok(), false => None, }; @@ -2010,7 +2010,7 @@ mod tests { let session = crate::lite::test_transport::SinkSession::gated_bi(gate.consume()); let log = session.log.clone(); - let handle = crate::auth::Handle::new(true); + let handle = crate::auth::Handle::ietf(true); let (driver, goaway) = start(Config { runtime: crate::time::Clock::sim(), session, @@ -2046,19 +2046,19 @@ mod tests { } } - /// A peer that never offered MoQ Auth sees no Auth request, the session keeps - /// working, and every token fails as unsupported rather than hanging. + /// A peer that never offered MoQ Auth sees no Auth request and the session keeps + /// working. A token resolves promptly rather than hanging, kept with an unknown grant + /// for the requests that name it. #[moq_net_sim::test] async fn a_peer_without_auth_sees_no_auth_request() { let session = auth_session(false); let (handle, log) = (&session.handle, &session.log); - let err = moq_net_sim::timeout(std::time::Duration::from_secs(1), handle.add("token")) + let auth = moq_net_sim::timeout(std::time::Duration::from_secs(1), handle.add("token")) .await - .expect("unsupported promptly") - .err() - .expect("no AUTH on this session"); - assert!(matches!(err, Error::Unsupported), "{err:?}"); + .expect("resolved promptly") + .expect("kept for requests"); + assert_eq!(auth.grant().peek(), None, "no grant without the extension"); assert_eq!(handle.grant().peek(), None, "no grant without the extension"); for _ in 0..ANNOUNCE_TURNS { diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index 98e00ea0427..c0cc45b2836 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -8448,7 +8448,7 @@ mod tests { ) -> ( Subscriber, crate::auth::Requests, - crate::auth::Token, + crate::auth::Auth, origin::Consumer, crate::lite::test_transport::ScriptedSession, ) { @@ -8466,7 +8466,7 @@ mod tests { ) -> ( Subscriber, crate::auth::Requests, - crate::auth::Token, + crate::auth::Auth, origin::Consumer, S, ) { diff --git a/rs/moq-net/src/lite/session.rs b/rs/moq-net/src/lite/session.rs index 4395977ff34..596f06890b7 100644 --- a/rs/moq-net/src/lite/session.rs +++ b/rs/moq-net/src/lite/session.rs @@ -479,7 +479,7 @@ struct Present { /// Whether the first poll decided who answers the peer's tokens. started: bool, /// The connection's own credential, held for the life of the session. - _setup: Option, + _setup: Option, } impl Present { diff --git a/rs/moq-net/src/model/origin.rs b/rs/moq-net/src/model/origin.rs index 58043030d20..f52d859a3ef 100644 --- a/rs/moq-net/src/model/origin.rs +++ b/rs/moq-net/src/model/origin.rs @@ -460,6 +460,16 @@ pub struct Route { /// most recently announced route. See [`Cost`]. pub cost: Cost, + /// The credential the request advertising this route carries, from + /// [`auth::Handle::add`](crate::auth::Handle::add), or `None` for the session's scope. + /// + /// Only a session the auth was presented on carries it: any other advertises the + /// route unnamed. Swapping it on a standing advertisement is an update that renews + /// the request in place (moq-transport REQUEST_UPDATE). moq-lite carries no + /// per-request auth yet, so there it rides the session's scope. Local, like + /// [`Self::source`]: a relay never forwards it. + pub auth: Option, + /// The announcing session's declared or assigned identity. /// /// Local selection state: split-horizon matches this as well as the chain, so a @@ -477,6 +487,7 @@ impl Default for Route { epoch: None, hops: Hops::new(), cost: Cost::default(), + auth: None, via: Hop::UNKNOWN, source: Source::Local, } @@ -517,6 +528,12 @@ impl Route { self } + /// Set the credential the advertising request carries; see [`Self::auth`]. + pub fn with_auth(mut self, auth: crate::auth::Auth) -> Self { + self.auth = Some(auth); + self + } + /// The announcing session's declared or assigned identity, for split-horizon. /// /// Not part of the advertised route: an assigned identity is private selection @@ -623,8 +640,9 @@ fn route_order<'a>( ) } -/// The `(hops, cost, source, epoch)` metadata an announce cursor delivers alongside a prefix. -type RouteMeta = (Hops, Cost, Source, Option); +/// The `(hops, cost, source, epoch, auth)` metadata an announce cursor delivers alongside a +/// prefix. +type RouteMeta = (Hops, Cost, Source, Option, Option); /// The earlier of two optional deadlines. fn earliest(a: Option, b: Option) -> Option { @@ -958,6 +976,7 @@ impl OriginConsumerState { via: Hop::UNKNOWN, source: meta.2, epoch: meta.3, + auth: meta.4, }, }) } @@ -997,6 +1016,8 @@ struct RouteEntry { scope: Patterns, hops: Hops, cost: Cost, + /// The auth the request advertising the route carries; see [`Route::auth`]. + auth: Option, /// The rank of the link the entry arrived on; see [`Producer::with_preference`]. preference: Preference, /// The announcing session's declared or assigned identity. Split-horizon @@ -1295,7 +1316,13 @@ impl TableCursor { fn update(&mut self, presented: &PathOwned, best: Option<&RouteEntry>, now: u64, hold: &Hold) { match best { Some(entry) => { - let meta = (entry.hops.clone(), entry.cost, entry.entered(), entry.epoch.clone()); + let meta = ( + entry.hops.clone(), + entry.cost, + entry.entered(), + entry.epoch.clone(), + entry.auth.clone(), + ); let served = entry.server.is_some(); let captures = self.captures(&entry.prefix); let instance = entry.instance(); @@ -2134,6 +2161,7 @@ impl Announcing { scope: self.scope.clone(), hops: route.hops.clone(), cost: route.cost, + auth: route.auth.clone(), preference: self.preference, via, local: self.local, @@ -2261,6 +2289,7 @@ impl AnnounceProducer { entry.epoch = route.epoch.clone(); entry.stale = stale; entry.cost = route.cost; + entry.auth = route.auth.clone(); entry.via = route.via; entry.advertised = true; let claim = entry.claim.clone(); @@ -2288,6 +2317,7 @@ impl AnnounceProducer { epoch: entry.epoch.clone(), hops: entry.hops.clone(), cost: entry.cost, + auth: entry.auth.clone(), via: entry.via, source: entry.entered(), }) @@ -6558,6 +6588,28 @@ mod tests { } } + /// Swapping a route's auth is an update in place carrying the new auth, so the session + /// advertising it can renew its request. + #[test] + #[cfg(feature = "async")] + fn an_auth_swap_is_an_update() { + let handle = crate::auth::Handle::ietf(false); + let first = handle.present(bytes::Bytes::from_static(b"first"), false).unwrap(); + let second = handle.present(bytes::Bytes::from_static(b"second"), false).unwrap(); + let producer = origin(1).produce(); + let mut announced = producer.consume().announced(); + let route = producer + .dynamic("room", Route::default().with_auth(first.clone())) + .unwrap(); + assert_eq!(announced.assert_next_active("room").auth, Some(first)); + + route.update(Route::default().with_auth(second.clone())).unwrap(); + match announced.next().now_or_never().expect("next blocked").expect("no next") { + AnnounceEvent::Update(update) => assert_eq!(update.route.auth, Some(second)), + other => panic!("should be an update: got {other:?}"), + } + } + /// An epochless replacement at equal cost and chain length on a chain whose hash /// loses competes with the lingering old route: nothing restarts and requests stay /// on the old route until it is withdrawn, then consumers restart and a re-request diff --git a/rs/moq-net/src/server.rs b/rs/moq-net/src/server.rs index 371e347a161..74ced586705 100644 --- a/rs/moq-net/src/server.rs +++ b/rs/moq-net/src/server.rs @@ -455,8 +455,12 @@ impl Server { origin: None, token, assigned_hop: crate::Hop::random(), - // Lite 01/02 and moq-transport 14-16 carry no AUTH. - auth: crate::auth::Handle::new(false), + // Lite 01/02 and moq-transport 14-16 carry no AUTH, though a moq-transport request + // still carries a token. + auth: match version { + Version::Ietf(_) => crate::auth::Handle::ietf(false), + Version::Lite(_) => crate::auth::Handle::new(false), + }, inner: Some(RequestInner { server: self.clone(), runtime, @@ -495,7 +499,7 @@ impl Server { assigned_hop: crate::Hop::random(), // The client's SETUP already settled whether it offered MoQ Auth, so it is // negotiated exactly when we offer it too. - auth: crate::auth::Handle::new(peer_setup.declared.auth && self.extensions.auth), + auth: crate::auth::Handle::ietf(peer_setup.declared.auth && self.extensions.auth), inner: Some(RequestInner { server: self.clone(), runtime, @@ -1485,7 +1489,7 @@ mod tests { origin: None, token: None, assigned_hop: Hop::random(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Handle::ietf(false), inner: Some(RequestInner { server: Server::new().with_publisher(&origin), runtime: Clock::new(moq_net_sim::now()), diff --git a/rs/moq-net/tests/auth.rs b/rs/moq-net/tests/auth.rs index ee2f9e96a9b..0469fbfe969 100644 --- a/rs/moq-net/tests/auth.rs +++ b/rs/moq-net/tests/auth.rs @@ -133,6 +133,63 @@ async fn moqt_16_has_no_grant() { older_versions_have_no_grant("moq-transport-16").await } +#[moq_net_sim::test] +async fn moqt_16_route_auth_renews_its_announce() { + a_route_auth_renews_its_announce("moq-transport-16").await +} + +#[moq_net_sim::test] +async fn moqt_22_route_auth_renews_its_announce() { + a_route_auth_renews_its_announce(MOQT_22).await +} + +/// A route's auth reaches the peer's acceptor on the announcement, and swapping it +/// renews the announcement with the new token, keeping it live. The client declines MoQ Auth, so every token the server sees +/// rode a request, and the server declines MoQ Solicit, so the client announces with +/// PUBLISH_NAMESPACE rather than answering a SUBSCRIBE_NAMESPACE. +async fn a_route_auth_renews_its_announce(version: &'static str) { + within(async { + let declined = |f: fn(&mut moq_net::setup::Extensions)| { + let mut extensions = moq_net::setup::Extensions::default(); + f(&mut extensions); + Some(extensions) + }; + let client_origin = produce_origin(2); + let server_origin = produce_origin(1); + let mut pair = connect(Options { + version: Some(version), + client_publish: Some(client_origin.clone()), + server_subscribe: Some(server_origin.clone()), + server_requests: true, + client_extensions: declined(|e| e.auth = false), + server_extensions: declined(|e| e.solicit = false), + ..Default::default() + }) + .await; + let mut tokens = serve(pair.requests.take().unwrap(), |_| Some(Grant::all())); + + let first = pair.client.auth().add("first").await.expect("kept for requests"); + let broadcast = client_origin.create_broadcast("room/x").unwrap(); + broadcast + .announce(origin::Route::default().with_auth(first)) + .unwrap(); + let (token, _first) = tokens.next().await.expect("the announce's token"); + assert_eq!(token, b"first"); + wait_announced(&server_origin.consume(), "room/x", true).await; + + let second = pair.client.auth().add("second").await.expect("kept for requests"); + broadcast + .announce(origin::Route::default().with_auth(second)) + .unwrap(); + let (token, _second) = tokens.next().await.expect("the renewal's token"); + assert_eq!(token, b"second"); + wait_announced(&server_origin.consume(), "room/x", true).await; + assert_eq!(pair.client_transport.close_reason(), None); + }) + .await + .expect("timed out"); +} + /// Build an origin producer, spawning its driver on the ambient runtime. fn produce_origin(hop: u64) -> origin::Producer { let (producer, driver) = origin::Producer::new(origin::Config::new(Hop::new(hop).unwrap())); @@ -206,6 +263,9 @@ struct Options { server_subscribe: Option, /// Take the server's AUTH requests before its driver runs. server_requests: bool, + /// Each side's extensions, when not the defaults. + client_extensions: Option, + server_extensions: Option, version: Option<&'static str>, } @@ -224,6 +284,9 @@ async fn connect(opts: Options) -> Pair { let (client_transport, server_transport) = create_mock_session_pair(Some(version.alpn())); let mut client = Client::new().with_versions(version.into()); + if let Some(extensions) = opts.client_extensions { + client = client.with_extensions(extensions); + } if let Some(publish) = &opts.client_publish { client = client.with_publisher(publish); } @@ -232,6 +295,9 @@ async fn connect(opts: Options) -> Pair { } let mut server = Server::new().with_versions(version.into()); + if let Some(extensions) = opts.server_extensions { + server = server.with_extensions(extensions); + } if let Some(publish) = &opts.server_publish { server = server.with_publisher(publish); } @@ -703,12 +769,21 @@ async fn a_reset_auth_stream_reports_unsupported(version: &'static str) { .expect("timed out"); } -/// Versions without AUTH never open the stream: no grant, and no way to add a token. +/// Versions without AUTH never open the stream: no grant. moq-transport keeps an added +/// token, with a grant that stays unknown, for the requests that name it; moq-lite has +/// nothing that could carry one. async fn older_versions_have_no_grant(version: &'static str) { within(async { let (pair, _broadcast) = connect_announced(version).await; assert_eq!(pair.client.auth().grant().peek(), None); - assert!(matches!(pair.client.auth().add("token").await, Err(Error::Unsupported))); + match version.starts_with("moq-lite") { + true => assert!(matches!(pair.client.auth().add("token").await, Err(Error::Unsupported))), + false => { + let auth = pair.client.auth().add("token").await.expect("kept for requests"); + assert_eq!(auth.grant().peek(), None); + assert_eq!(pair.client.auth().grant().peek(), None); + } + } // moq-transport's request streams have no stream type to look for. if version.starts_with("moq-lite") { for transport in [&pair.client_transport, &pair.server_transport] { @@ -769,6 +844,7 @@ async fn a_revoked_grant_cancels_its_subscriptions(version: &'static str) { server_publish: Some(server_origin.clone()), server_subscribe: Some(server_origin.clone()), server_requests: true, + ..Default::default() }) .await; let mut issued = serve(pair.requests.take().unwrap(), |token| { diff --git a/rs/moq-net/tests/extensions.rs b/rs/moq-net/tests/extensions.rs index 2245bda4ae6..3ce750056dc 100644 --- a/rs/moq-net/tests/extensions.rs +++ b/rs/moq-net/tests/extensions.rs @@ -106,7 +106,8 @@ async fn a_side_that_declines_solicit_takes_unsolicited_announces() { } /// A side that declines MoQ Auth leaves it un-negotiated whichever side declines: -/// neither side holds a grant, and a token is unsupported rather than presented. +/// neither side holds a grant, and a token is kept for the requests that name it +/// rather than presented on an AUTH stream. #[moq_net_sim::test] async fn a_declined_auth_extension_is_not_negotiated() { for name in AUTH { @@ -136,12 +137,14 @@ async fn a_declined_auth_extension_is_not_negotiated() { for session in [&pair.client, &pair.server] { assert_eq!(session.auth().grant().peek(), None, "{context}"); - let added = moq_net_sim::timeout(SETTLE, session.auth().add("token")).await; - assert!( - matches!(added, Ok(Err(Error::Unsupported))), - "{context}: {:?}", - added.map(|res| res.map(|_| ())) - ); + // Only a request that names it can carry the token, so it resolves at once with + // a grant that stays unknown. + let added = moq_net_sim::timeout(SETTLE, session.auth().add("token")) + .await + .unwrap_or_else(|_| panic!("{context}: add never resolved")) + .unwrap_or_else(|err| panic!("{context}: {err:?}")); + assert_eq!(added.grant().peek(), None, "{context}"); + assert_eq!(session.auth().grant().peek(), None, "{context}"); } assert_open(&pair, &context); } From bae9b79adc779a3d2ad0d37a73aee7a9731b1d11 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 16 Oct 2026 10:31:24 -0700 Subject: [PATCH 05/14] fix(net): keep the session when a renewal meets a cancelled announce A FIN on an update in flight is the peer cancelling the request, and an update written after the peer ended it fails only that request: both now withdraw and re-offer the namespace instead of closing the session. Renewals in flight are polled by which stream woke, with deadlines in send order, so many renewing at once costs per answer, not per request held. Adds the auth/renewal bench swept over routes and sessions, renames moq-tokio's connection::auth types to Handle and Auth, and documents Route::auth. Co-Authored-By: Claude Opus 5.5 --- doc/concept/moq-lite.md | 5 +- doc/concept/standard.md | 2 +- rs/moq-net/benches/auth.rs | 109 ++++++++++++- rs/moq-net/src/auth.rs | 5 +- rs/moq-net/src/ietf/publisher.rs | 245 +++++++++++++++++++++++----- rs/moq-net/src/model/origin.rs | 9 +- rs/moq-net/tests/auth.rs | 113 +++++++++---- rs/moq-net/tests/extensions.rs | 2 +- rs/moq-relay/benches/auth.rs | 4 +- rs/moq-tokio/src/connection.rs | 5 +- rs/moq-tokio/src/connection/auth.rs | 26 +-- rs/moq-tokio/src/lib.rs | 2 +- 12 files changed, 424 insertions(+), 103 deletions(-) diff --git a/doc/concept/moq-lite.md b/doc/concept/moq-lite.md index 9e6d534668d..60d0e24febe 100644 --- a/doc/concept/moq-lite.md +++ b/doc/concept/moq-lite.md @@ -180,7 +180,10 @@ code and the session stays up. A client that publishes outside its grant closes the session with `UNAUTHORIZED`, naming the path. moq-transport carries the same exchange on draft-17+ through the [MoQ Auth extension](/draft/moq-auth), limited to namespace prefixes. Older versions have no grant; the URL token keeps working -everywhere. +everywhere. On moq-transport an added token is kept even without the extension, +so an announcement can name it: it rides that `PUBLISH_NAMESPACE` as its +authorization token. moq-lite has no per-request token yet, so there an +announcement rides the session's scope. ## Subscriptions diff --git a/doc/concept/standard.md b/doc/concept/standard.md index 12294d9f6d0..a7c8707cd31 100644 --- a/doc/concept/standard.md +++ b/doc/concept/standard.md @@ -29,7 +29,7 @@ subset drops. What a peer actually observes against this implementation: - **History is one group.** In Rust, Object ID gaps must be marked by Prior Object ID Gap properties for a group to be fetchable. A `FETCH` returns one group from the cache, or on drafts 14 through 19 the saved prefix of the group a new subscription just joined. A range of groups is refused, as is a draft 20+ `LOCATION_FILTER` that ends at Largest Object rather than an absolute End Location. On draft 20 and later, `FETCH_OK`'s End Location is the requested end, capped at Largest Object: a range holding no objects gets an empty fetch stream, and only a start past Largest Object is refused, with `INVALID_RANGE`. A relay's copy with no upstream subscription can't know Largest Object short of the track's end, so it echoes the requested end instead. JavaScript publishing refuses every `FETCH`. Datagrams are never fetchable. A Rust reader that only fetches learns the track from `TRACK_STATUS` instead of subscribing, so a finished track stays fetchable; draft 17 still subscribes, since its `TRACK_STATUS` answer cannot say whether the track is timed, and so does a reader whose publisher refuses `TRACK_STATUS` as `NOT_SUPPORTED`. - **Timing.** A track whose `SUBSCRIBE_OK` declares no `TIMESCALE` is untimed, as is every track on drafts 14 through 16. A Rust reader that never subscribed takes the units from `TRACK_STATUS` on draft 18 and later. - **Strict SETUP and parameters.** Repeated unknown `SETUP` options, GREASE included, are accepted if well-formed; a repeated known option is rejected. On draft 17 and later, a `GROUP_ORDER` other than Ascending (1) or Descending (2) closes the session. Message parameters follow the negotiated draft's lists: drafts 14 and 15 ignore an unknown or misplaced parameter, draft 16 ignores one defined only for another message but closes the session on an unknown one, and draft 17 and later close it on both. -- **One `SETUP` credential**, forwarded to the [auth server](/bin/relay/auth#the-contract) unverified. A second `SETUP` token closes the session. We advertise no token cache, so an alias reference in `SETUP` closes it with `PROTOCOL_VIOLATION`, one on a request with `UNKNOWN_AUTH_TOKEN_ALIAS`, and a registration on a request with `AUTH_TOKEN_CACHE_OVERFLOW`. Where the Rust side authorizes the peer or verifies tokens, a token on a `SUBSCRIBE` or `PUBLISH_NAMESPACE` is verified like an in-band session token and authorizes that one request alone, even where that authorization already covers it; it never widens the session. It is refused with `NOT_SUPPORTED` when nothing verifies it, when it is empty, or when its Token Type is anything but 0 (out of band). A side that does neither, JavaScript included, ignores the token. Rust refuses a request carrying more than one distinct token with `NOT_SUPPORTED`, since it verifies one; JavaScript, which verifies none, ignores them all. A token-authorized `SUBSCRIBE` still stays within the grant the peer gave us. A token-authorized `PUBLISH_NAMESPACE` needs a grant covering everything beneath the namespace, and the tracks we subscribe under it rely on that announce instead of our limit, ending with it. An update renews the token, except on a drafts 14 and 15 `PUBLISH_NAMESPACE`, which has no update, and a refused renewal ends only that request. A token on any other request is decoded and grants nothing, and a request repeating one is refused with `MALFORMED_AUTH_TOKEN`. `SUBSCRIBE_TRACKS` reads its namespace before refusal; its token is not decoded. On draft 17 and later, the negotiated [MoQ Auth extension](/concept/moq-lite#authorization) accepts additional session tokens in band. +- **One `SETUP` credential**, forwarded to the [auth server](/bin/relay/auth#the-contract) unverified. A second `SETUP` token closes the session. We advertise no token cache, so an alias reference in `SETUP` closes it with `PROTOCOL_VIOLATION`, one on a request with `UNKNOWN_AUTH_TOKEN_ALIAS`, and a registration on a request with `AUTH_TOKEN_CACHE_OVERFLOW`. Where the Rust side authorizes the peer or verifies tokens, a token on a `SUBSCRIBE` or `PUBLISH_NAMESPACE` is verified like an in-band session token and authorizes that one request alone, even where that authorization already covers it; it never widens the session. It is refused with `NOT_SUPPORTED` when nothing verifies it, when it is empty, or when its Token Type is anything but 0 (out of band). A side that does neither, JavaScript included, ignores the token. Rust refuses a request carrying more than one distinct token with `NOT_SUPPORTED`, since it verifies one; JavaScript, which verifies none, ignores them all. A token-authorized `SUBSCRIBE` still stays within the grant the peer gave us. A token-authorized `PUBLISH_NAMESPACE` needs a grant covering everything beneath the namespace, and the tracks we subscribe under it rely on that announce instead of our limit, ending with it. An update renews the token, except on a drafts 14 and 15 `PUBLISH_NAMESPACE`, which has no update, and a refused renewal ends only that request. Rust sends the token a route names (`Route::auth`) on its unsolicited `PUBLISH_NAMESPACE`, and swapping it renews the request with `REQUEST_UPDATE` from draft 17, or ends and re-sends it before; an answer to a solicited `SUBSCRIBE_NAMESPACE` carries no token. A token on any other request is decoded and grants nothing, and a request repeating one is refused with `MALFORMED_AUTH_TOKEN`. `SUBSCRIBE_TRACKS` reads its namespace before refusal; its token is not decoded. On draft 17 and later, the negotiated [MoQ Auth extension](/concept/moq-lite#authorization) accepts additional session tokens in band. - **Refused, not fatal.** A legal request this stack does not serve is rejected on its own and the session stays up: `FORWARD=0`, `SUBSCRIBE_TRACKS`, and the fetch forms above get `NOT_SUPPORTED`. Range Filters get `INVALID_FILTER` on draft 19 and later because the advertised filter limit is zero. On draft 21 and later, namespaces whose first tuple field is exactly `.` or `.session` get `DOES_NOT_EXIST` before application lookup; other dot prefixes still reach the application. Subscription updates accept priority and delivery timeouts; Rust also renews request tokens. Other supported-but-unimplemented preferences end that subscription; a `TRACK_PROPERTY_FILTER` there closes the session with `PROTOCOL_VIOLATION`, since it belongs only to `SUBSCRIBE_TRACKS`. On draft 18 and later a namespace update may change its prefix; suffixes after the acknowledgement are relative to that new prefix. - **Objects and datagrams.** Rust relays preserve Object IDs, gaps, object properties, FIRST\_OBJECT, and END\_OF\_GROUP status objects between moq-transport peers for subgroup 0. Datagrams retain their IDs, properties, priority, and status. Plain Rust readers and moq-lite peers see consecutive frames and normal datagram payloads; JavaScript supports only normal datagrams at Object ID 0. A malformed datagram closes the session; a relayed one the downstream draft cannot carry is dropped. A relayed Timestamp leaves in the units the downstream response declares, and is dropped where it declares none (drafts 14 through 16), as for a plain frame; an immutable one that would change or vanish is refused. Only property 0x10 is read as a Timestamp. Property 0x06 stays opaque and keeps its value regardless of timestamp units; it is dropped across the draft-19 boundary, or refused when immutable, because draft 19 assigns it to SUBGROUP\_DELIVERY\_TIMEOUT. From draft 15, a status object carrying properties is refused, and one relayed from draft 14 leaves without them. Draft 14 allows them, but an END\_OF\_TRACK carrying properties ends the subscription as unsupported, since a track end has nowhere to keep them. A `FETCH` carries the END\_OF\_GROUP object on drafts 14 and 15; later drafts have no FETCH status, so their response ends at the last frame. A group cannot resume across route changes by frame ordinal once Object IDs skip ahead of the ordinals on either route; recovery refuses it instead of splicing different objects. - **Priority.** Higher is served first. The IETF default of 128 is this stack's 127, and a track that never sets one is 127. diff --git a/rs/moq-net/benches/auth.rs b/rs/moq-net/benches/auth.rs index 044c88254ac..f5ca01859fe 100644 --- a/rs/moq-net/benches/auth.rs +++ b/rs/moq-net/benches/auth.rs @@ -2,6 +2,12 @@ //! //! Measures the pending queue and complete grant/withdrawal lifecycle. Run the same //! matrix before and after queue changes so a per-session scan appears on its own axis. +//! +//! `renewal` times swapping the auth of every announced route on one origin, until +//! the session that presented both auths has renewed each announcement and its peer +//! accepted every token. It sweeps the routes and the sessions publishing that origin: +//! the others carry no auth for those routes, so their cost is waking on a change that +//! sends nothing. #[path = "../tests/support/mod.rs"] mod support; @@ -9,7 +15,7 @@ mod support; use bytes::Bytes; use criterion::{BenchmarkId, Criterion, Throughput, criterion_group, criterion_main}; use futures::future::join_all; -use moq_net::{Session, Version, auth}; +use moq_net::{Hop, Session, Version, auth, origin}; use std::time::Duration; use support::{harness, mock::create_mock_session_pair}; @@ -98,5 +104,104 @@ fn admission(c: &mut Criterion) { } } -criterion_group!(benches, admission); +/// A client publishing `origin` to its own server, whose session and token requests follow. +async fn publisher(origin: &origin::Producer) -> (Session, Session, auth::Requests) { + let version: Version = "moq-transport-22".parse().unwrap(); + let (client, server) = create_mock_session_pair(Some(version.alpn())); + // Tokens ride requests alone, and the client announces with PUBLISH_NAMESPACE. + let mut client_extensions = moq_net::setup::Extensions::default(); + client_extensions.auth = false; + let mut server_extensions = moq_net::setup::Extensions::default(); + server_extensions.solicit = false; + let (subscribe, driver) = origin::Producer::new(origin::Config::new(Hop::new(1).unwrap())); + harness::spawn(driver); + let client = async { + let (session, driver) = moq_net::Client::new() + .with_versions(version.into()) + .with_extensions(client_extensions) + .with_publisher(origin.consume()) + .connect(harness::now(), client) + .await + .unwrap(); + harness::spawn(driver); + session + }; + let server = async { + let request = moq_net::Server::new() + .with_versions(version.into()) + .with_extensions(server_extensions) + .with_subscriber(subscribe) + .accept_request(harness::now(), server) + .await + .unwrap(); + let requests = request.auth().requests().unwrap(); + let (session, driver) = request.ok().await.unwrap(); + harness::spawn(driver); + (session, requests) + }; + let (client, (server, requests)) = futures::join!(client, server); + (client, server, requests) +} + +fn renewal(c: &mut Criterion) { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let mut group = c.benchmark_group("auth/renewal"); + group.sample_size(10); + group.warm_up_time(Duration::from_millis(100)); + group.measurement_time(Duration::from_millis(300)); + for sessions in [1, 16, 64] { + for routes in [1, 16, 128] { + group.throughput(Throughput::Elements(routes as u64)); + group.bench_with_input( + BenchmarkId::new(sessions.to_string(), routes), + &(sessions, routes), + |b, &(sessions, routes)| { + let _guard = runtime.enter(); + // No hold, so an iteration times the renewal rather than the origin batching it. + let mut config = origin::Config::new(Hop::new(2).unwrap()); + config.update_hold = Duration::ZERO; + let (origin, driver) = origin::Producer::new(config); + harness::spawn(driver); + let mut publishers = runtime.block_on(join_all((0..sessions).map(|_| publisher(&origin)))); + let (session, _, requests) = &mut publishers[0]; + let handle = session.auth(); + let auths = + runtime.block_on(async { [handle.add("a").await.unwrap(), handle.add("b").await.unwrap()] }); + let broadcasts: Vec<_> = (0..routes) + .map(|route| origin.create_broadcast(format!("room/{route}").as_str()).unwrap()) + .collect(); + let mut swap = |auth: &auth::Auth| { + for broadcast in &broadcasts { + broadcast + .announce(origin::Route::default().with_auth(auth.clone())) + .unwrap(); + } + runtime.block_on(async { + let mut issued = Vec::with_capacity(routes); + for _ in 0..routes { + issued.push(requests.next().await.unwrap().accept(auth::Grant::all())); + } + issued + }) + }; + // A round's grants outlive the next round, whose updates go out only once + // the server applied this one's: dropping a grant sooner can end its + // request before the renewal that replaces it lands. + let mut held = [swap(&auths[0]), Vec::new()]; + let mut next = 1; + b.iter(|| { + held[next] = swap(&auths[next]); + next ^= 1; + }); + }, + ); + } + } + group.finish(); +} + +criterion_group!(benches, admission, renewal); criterion_main!(benches); diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index 4ff74ee4d88..e8a29ca84ce 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -1943,7 +1943,10 @@ mod tests { assert!(ietf.state.read().opening.is_empty(), "nothing to open a stream for"); let lite = Handle::new(false); - assert!(matches!(lite.present(Bytes::from_static(b"jwt"), false), Err(Error::Unsupported))); + assert!(matches!( + lite.present(Bytes::from_static(b"jwt"), false), + Err(Error::Unsupported) + )); } /// A peer that turns out not to speak AUTH leaves a moq-transport auth for its diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index 1c4875662a4..5b815759c4c 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -1,7 +1,7 @@ use crate::{frame, group, origin, track}; use futures::FutureExt as _; use std::{ - collections::{HashMap, HashSet, VecDeque}, + collections::{HashMap, VecDeque}, ops::Bound, sync::{ Arc, @@ -417,8 +417,13 @@ struct Namespaces { /// waits for credit beside the loop's other work and computes its update when it gets /// one, so a later change supersedes an earlier one that is still waiting. updates: VecDeque, - /// Requests with an update in flight, each answered on its own stream. - inflight: HashSet, + /// Requests with an update in flight, each answered on its own stream, by when each + /// gives up. Sent in order with one timeout, so the front is the earliest; an entry + /// whose request moved on is skipped when it reaches the front. + inflight: VecDeque<(crate::time::Instant, crate::PathOwned)>, + /// Requests whose stream woke the loop, so a wake polls those rather than every + /// update in flight. + woken: Arc, /// What we may advertise: our grant (MoQ Auth) and the ceiling on the peer. permit: crate::auth::Permit, /// The auth epoch last applied to `permit`. @@ -435,7 +440,8 @@ impl Namespaces { watched: HashMap::new(), requests: HashMap::new(), updates: VecDeque::new(), - inflight: HashSet::new(), + inflight: VecDeque::new(), + woken: Default::default(), permit: Default::default(), epoch: 0, hidden: false, @@ -499,8 +505,54 @@ enum NamespaceEvent { Regrant(crate::auth::Permit), /// The queued update at the front got this request ID. Renew(RequestId), - /// An update in flight was answered, or `None` once its deadline passed. - Answered(crate::PathOwned, Option>), + /// What came back for an update in flight. + Answered(crate::PathOwned, Result), +} + +/// What came back for an update in flight. +enum Answer { + /// The peer's REQUEST_OK or REQUEST_ERROR. + Message(super::request_stream::Framed), + /// The peer ended the request instead, a cancel from draft 17. + Ended, + /// Nothing before its deadline. + Late, +} + +/// The requests whose stream woke an announce loop since it last looked. +#[derive(Default)] +struct Woken { + suffixes: std::sync::Mutex>, + /// The loop, woken in turn. + owner: std::sync::Mutex>, +} + +impl Woken { + /// A waker that marks `suffix` woken. + fn waker(self: &Arc, suffix: crate::PathOwned) -> std::task::Waker { + std::task::Waker::from(Arc::new(RequestWaker { + woken: self.clone(), + suffix, + })) + } +} + +struct RequestWaker { + woken: Arc, + suffix: crate::PathOwned, +} + +impl std::task::Wake for RequestWaker { + fn wake(self: Arc) { + self.wake_by_ref() + } + + fn wake_by_ref(self: &Arc) { + self.woken.suffixes.lock().unwrap().push(self.suffix.clone()); + if let Some(owner) = &*self.woken.owner.lock().unwrap() { + owner.wake_by_ref(); + } + } } #[derive(Clone)] @@ -2747,7 +2799,8 @@ where // An update already waiting or in flight carries whatever is current once it goes // out, and its answer brings the loop back here for anything after that. if wanted - && !restart && let Some(request) = request + && !restart + && let Some(request) = request && !unchanged { if !request.queued && request.pending.is_none() { @@ -2933,6 +2986,7 @@ where auth, queued: false, pending: None, + waker: None, }, ); Ok(Refused::No) @@ -2985,7 +3039,9 @@ where && request.pending.is_none() && ns.watched.get(suffix).is_some_and(|watch| { self.offer(ns, suffix, &prefix.join(suffix)) - .is_some_and(|(advert, auth)| advert.wanted() && (advert != watch.sent || auth != request.auth)) + .is_some_and(|(advert, auth)| { + advert.wanted() && (advert != watch.sent || auth != request.auth) + }) }) }); if wants { @@ -3034,36 +3090,75 @@ where update.authorization_token = auth.as_ref().and_then(|auth| self.request_token(auth)); } tracing::debug!(broadcast = %self.origin.absolute(&request.path), "publish_namespace update"); - request.stream.writer.varint(ietf::PublishNamespaceUpdate::ID).await?; - request.stream.writer.encode(&update).await?; + let written = async { + request.stream.writer.varint(ietf::PublishNamespaceUpdate::ID).await?; + request.stream.writer.encode(&update).await + } + .await; + if let Err(err) = written { + // The peer ended the request before the update reached it, which ends only this + // advertisement: closing our side completes the withdrawal, and the retry + // re-offers it fresh. + tracing::debug!(%err, "publish_namespace update not sent"); + self.withdraw_namespace(&mut ns.target, &mut ns.requests, suffix.clone()) + .await?; + if let Some(watch) = ns.watched.get_mut(&suffix) { + watch.refused = Refused::No; + watch.deferred = Refused::No.pending(); + watch.sent = Advert::None; + } + return Ok(()); + } // The peer holds the update unless its answer says otherwise. - request.pending = Some(self.runtime.now() + ADVERTISE_TIMEOUT); + let deadline = self.runtime.now() + ADVERTISE_TIMEOUT; + request.pending = Some(deadline); request.auth = auth; watch.sent = advert; - ns.inflight.insert(suffix); + // Marked woken, so the loop's next pass reads the answer it may already have. + ns.woken.suffixes.lock().unwrap().push(suffix.clone()); + ns.inflight.push_back((deadline, suffix)); Ok(()) } - /// The answer to an update in flight, or `None` for one past its deadline. + /// What came back for an update in flight, polling only the requests that woke. fn poll_answer( requests: &mut HashMap>, - inflight: &HashSet, + inflight: &mut VecDeque<(crate::time::Instant, crate::PathOwned)>, + woken: &Arc, now: crate::time::Instant, cx: &mut std::task::Context<'_>, - ) -> Poll<(crate::PathOwned, Option>)> { - for suffix in inflight { - let Some(request) = requests.get_mut(suffix) else { + ) -> Poll<(crate::PathOwned, Result)> { + // Registered before the take, so a wake landing between them is not lost. + *woken.owner.lock().unwrap() = Some(cx.waker().clone()); + let mut suffixes = std::mem::take(&mut *woken.suffixes.lock().unwrap()); + while let Some(suffix) = suffixes.pop() { + let Some(request) = requests.get_mut(&suffix) else { continue; }; - let Some(deadline) = request.pending else { + if request.pending.is_none() { continue; - }; - if let Poll::Ready(answer) = request.stream.reader.poll_decode(cx) { - return Poll::Ready((suffix.clone(), Some(answer))); } - if now >= deadline { - return Poll::Ready((suffix.clone(), None)); + let waker = request.waker.get_or_insert_with(|| woken.waker(suffix.clone())).clone(); + if let Poll::Ready(answer) = request + .stream + .reader + .poll_decode_maybe(&mut std::task::Context::from_waker(&waker)) + { + // The rest stay woken for the next pass. + woken.suffixes.lock().unwrap().append(&mut suffixes); + let answer = answer.map(|answer| answer.map_or(Answer::Ended, Answer::Message)); + return Poll::Ready((suffix, answer)); + } + } + while let Some((deadline, suffix)) = inflight.front() { + let current = requests.get(suffix).and_then(|request| request.pending) == Some(*deadline); + if current && now < *deadline { + break; + } + let (_, suffix) = inflight.pop_front().expect("just peeked"); + if current { + return Poll::Ready((suffix, Ok(Answer::Late))); } } Poll::Pending @@ -3073,30 +3168,35 @@ where /// REQUEST_ERROR closed the stream and withdrew the advertisement (moq-transport Section /// 9.5.1), so the request is dropped and the retry re-offers it fresh. An unanswered /// update is dropped the same way, since a peer that ignored it cannot be assumed to hold - /// either version. + /// either version, and so is one the peer cancelled instead of answering. async fn answered( &self, ns: &mut Namespaces, prefix: &crate::PathOwned, suffix: crate::PathOwned, - answer: Option>, + answer: Result, ) -> Result<(), Error> { - ns.inflight.remove(&suffix); let Some(request) = ns.requests.get_mut(&suffix) else { return Ok(()); }; request.pending = None; let absolute = self.origin.absolute(&request.path).to_owned(); - let refused = match answer { - None => { + let refused = match answer? { + Answer::Late => { tracing::debug!(broadcast = %absolute, "no answer to the update"); // Abrupt: a peer that never answers is not owed the FIN handshake. ns.requests.remove(&suffix); Refused::No } - Some(answer) => { - let super::request_stream::Framed(type_id, body) = answer?; + Answer::Ended => { + tracing::debug!(broadcast = %absolute, "update cancelled"); + // The peer closed its side; closing ours completes the withdrawal. + self.withdraw_namespace(&mut ns.target, &mut ns.requests, suffix.clone()) + .await?; + Refused::No + } + Answer::Message(super::request_stream::Framed(type_id, body)) => { let mut data = body.decoder(self.version); match type_id { ietf::RequestOk::ID => { @@ -3504,10 +3604,6 @@ where } } retry.set(retry_at); - // Requests an update was withdrawn from leave the in-flight set here. - let Namespaces { requests, inflight, .. } = &mut ns; - inflight.retain(|suffix| requests.get(suffix).is_some_and(|request| request.pending.is_some())); - answer_by.set(inflight.iter().filter_map(|suffix| requests[suffix].pending).min()); self.prune_updates(&mut ns, &prefix); let event = { @@ -3516,6 +3612,7 @@ where epoch, requests, inflight, + woken, updates, .. } = &mut ns; @@ -3534,11 +3631,13 @@ where } // Each update in flight is answered on its own stream, so many renew at // once and none holds up the announcements behind it. - let _ = answer_by.poll(waiter); let now = self.runtime.now(); - if let Poll::Ready((suffix, answer)) = Self::poll_answer(requests, inflight, now, &mut cx) { + if let Poll::Ready((suffix, answer)) = Self::poll_answer(requests, inflight, woken, now, &mut cx) { return Poll::Ready(NamespaceEvent::Answered(suffix, answer)); } + // The front is now the earliest update still waiting. + answer_by.set(inflight.front().map(|(deadline, _)| *deadline)); + let _ = answer_by.poll(waiter); // Credit goes to queued updates in order, as the drafts before 17 require. if !updates.is_empty() && let Poll::Ready(id) = self.control.poll_next_request_id(waiter) @@ -4728,6 +4827,8 @@ struct NamespaceRequest { queued: bool, /// When to give up on the update in flight, which has no answer yet. pending: Option, + /// Marks this request woken when its stream has an answer. + waker: Option, } #[cfg(test)] @@ -12824,7 +12925,10 @@ mod tests { auth: &crate::auth::Handle, scripts: Vec>, version: Version, - ) -> (Publisher, crate::lite::test_transport::Log) { + ) -> ( + Publisher, + crate::lite::test_transport::Log, + ) { let session = crate::lite::test_transport::ScriptedSession::per_stream(scripts); let log = session.log.clone(); let publisher = Publisher::new( @@ -12875,7 +12979,9 @@ mod tests { let origin = unheld_origin(); let auth = crate::auth::Handle::ietf(false); let first = auth.present(bytes::Bytes::from_static(b"first-secret"), false).unwrap(); - let second = auth.present(bytes::Bytes::from_static(b"second-secret"), false).unwrap(); + let second = auth + .present(bytes::Bytes::from_static(b"second-secret"), false) + .unwrap(); let cam = origin .announce("cam", crate::origin::Route::default().with_auth(first)) .unwrap(); @@ -12886,7 +12992,11 @@ mod tests { let mut run = std::pin::pin!(publisher.run_publish_namespaces()); poll_until(&mut run, &log, b"cam", 1).await; assert_eq!(occurrences(&log, b"cam"), 1, "the advertisement never went out"); - assert_eq!(occurrences(&log, b"first-secret"), 1, "PUBLISH_NAMESPACE carries the auth"); + assert_eq!( + occurrences(&log, b"first-secret"), + 1, + "PUBLISH_NAMESPACE carries the auth" + ); cam.update(crate::origin::Route::default().with_auth(second)).unwrap(); let expected = request_update( @@ -12915,7 +13025,9 @@ mod tests { let origin = unheld_origin(); let other = crate::auth::Handle::ietf(false); - let foreign = other.present(bytes::Bytes::from_static(b"foreign-secret"), false).unwrap(); + let foreign = other + .present(bytes::Bytes::from_static(b"foreign-secret"), false) + .unwrap(); let _cam = origin .announce("cam", crate::origin::Route::default().with_auth(foreign)) .unwrap(); @@ -12927,7 +13039,11 @@ mod tests { poll_until(&mut run, &log, b"cam", 1).await; assert_eq!(occurrences(&log, b"cam"), 1, "the advertisement never went out"); - assert_eq!(occurrences(&log, b"foreign-secret"), 0, "sent another handle's credential"); + assert_eq!( + occurrences(&log, b"foreign-secret"), + 0, + "sent another handle's credential" + ); } /// moq-transport cannot name an auth on a request that had none, so naming one @@ -12965,7 +13081,9 @@ mod tests { let origin = unheld_origin(); let auth = crate::auth::Handle::ietf(false); let first = auth.present(bytes::Bytes::from_static(b"first-secret"), false).unwrap(); - let second = auth.present(bytes::Bytes::from_static(b"second-secret"), false).unwrap(); + let second = auth + .present(bytes::Bytes::from_static(b"second-secret"), false) + .unwrap(); let cam = origin .announce("cam", crate::origin::Route::default().with_auth(first)) .unwrap(); @@ -12980,7 +13098,11 @@ mod tests { poll_until(&mut run, &log, b"second-secret", 1).await; assert_eq!(occurrences(&log, b"second-secret"), 1, "the renewal never went out"); - assert_eq!(occurrences(&log, b"first-secret"), 1, "the first token was not repeated"); + assert_eq!( + occurrences(&log, b"first-secret"), + 1, + "the first token was not repeated" + ); assert_eq!(log.bi_opens(), 2, "a fresh request for the renewal"); } @@ -13030,6 +13152,43 @@ mod tests { ); } + /// An update the peer never answers drops its request once the timeout passes, and + /// the retry advertises the namespace afresh under the current auth. + #[moq_net_sim::test] + async fn an_unanswered_renewal_advertises_again() { + const VERSION: Version = Version::Draft19; + + let origin = unheld_origin(); + let auth = crate::auth::Handle::ietf(false); + let present = |token: &'static [u8]| auth.present(bytes::Bytes::from_static(token), false).unwrap(); + let cam = origin + .announce("cam", crate::origin::Route::default().with_auth(present(b"first"))) + .unwrap(); + settle().await; + + let ok = publish_namespace_ok(VERSION).await; + let (publisher, log) = auth_publisher(&origin, &auth, vec![ok.clone(), ok], VERSION); + let mut run = std::pin::pin!(publisher.run_publish_namespaces()); + poll_until(&mut run, &log, b"first", 1).await; + cam.update(crate::origin::Route::default().with_auth(present(b"second"))) + .unwrap(); + poll_until(&mut run, &log, b"second", 1).await; + assert_eq!(occurrences(&log, b"second"), 1, "the renewal never went out"); + + for _ in 0..20 { + if occurrences(&log, b"second") >= 2 { + break; + } + assert!(futures::poll!(run.as_mut()).is_pending()); + moq_net_sim::sleep(Duration::from_secs(1)).await; + } + assert_eq!( + occurrences(&log, b"second"), + 2, + "the unanswered renewal was never retried" + ); + } + /// moq-transport has no restart: another source winning a namespace without an epoch /// withdraws it and advertises it afresh, so the peer resubscribes. #[moq_net_sim::test] diff --git a/rs/moq-net/src/model/origin.rs b/rs/moq-net/src/model/origin.rs index f52d859a3ef..2f84eb6429c 100644 --- a/rs/moq-net/src/model/origin.rs +++ b/rs/moq-net/src/model/origin.rs @@ -463,10 +463,11 @@ pub struct Route { /// The credential the request advertising this route carries, from /// [`auth::Handle::add`](crate::auth::Handle::add), or `None` for the session's scope. /// - /// Only a session the auth was presented on carries it: any other advertises the - /// route unnamed. Swapping it on a standing advertisement is an update that renews - /// the request in place (moq-transport REQUEST_UPDATE). moq-lite carries no - /// per-request auth yet, so there it rides the session's scope. Local, like + /// Only a session the auth was presented on carries it, as the token on its + /// moq-transport PUBLISH_NAMESPACE: any other session, an answer to a solicited + /// SUBSCRIBE_NAMESPACE, and moq-lite (which rides the session's scope) advertise + /// the route unnamed. Swapping it is an update that renews the request with + /// REQUEST_UPDATE from draft 17, or ends and makes it again before. Local, like /// [`Self::source`]: a relay never forwards it. pub auth: Option, diff --git a/rs/moq-net/tests/auth.rs b/rs/moq-net/tests/auth.rs index 0469fbfe969..87d3508954f 100644 --- a/rs/moq-net/tests/auth.rs +++ b/rs/moq-net/tests/auth.rs @@ -143,44 +143,95 @@ async fn moqt_22_route_auth_renews_its_announce() { a_route_auth_renews_its_announce(MOQT_22).await } -/// A route's auth reaches the peer's acceptor on the announcement, and swapping it -/// renews the announcement with the new token, keeping it live. The client declines MoQ Auth, so every token the server sees -/// rode a request, and the server declines MoQ Solicit, so the client announces with +#[moq_net_sim::test] +async fn moqt_22_renewing_a_cancelled_announce_keeps_the_session() { + within(async { + let (mut pair, client_origin, _server_origin) = connect_route_auth(MOQT_22).await; + let mut tokens = serve(pair.requests.take().unwrap(), |_| Some(Grant::all())); + let first = pair.client.auth().add("first").await.expect("kept for requests"); + let broadcast = client_origin.create_broadcast("room/x").unwrap(); + broadcast.announce(origin::Route::default().with_auth(first)).unwrap(); + let (_, first) = tokens.next().await.expect("the announce's token"); + + // Ending the grant cancels the announcement, which the client has yet to read when + // the swap writes its update. + drop(first); + moq_net_sim::sleep(Duration::from_secs(1)).await; + let second = pair.client.auth().add("second").await.expect("kept for requests"); + broadcast.announce(origin::Route::default().with_auth(second)).unwrap(); + let (token, _second) = tokens.next().await.expect("announced again"); + assert_eq!(token, b"second"); + assert_eq!(pair.client_transport.close_reason(), None); + }) + .await + .expect("timed out"); +} + +#[moq_net_sim::test] +async fn moqt_22_a_cancelled_renewal_keeps_the_session() { + within(async { + let (mut pair, client_origin, _server_origin) = connect_route_auth(MOQT_22).await; + // The renewal's token goes unanswered, so its update is still in flight when the + // first grant ends and the server cancels the announcement. + let mut tokens = serve(pair.requests.take().unwrap(), |token| { + (token == b"first").then(Grant::all) + }); + let first = pair.client.auth().add("first").await.expect("kept for requests"); + let broadcast = client_origin.create_broadcast("room/x").unwrap(); + broadcast.announce(origin::Route::default().with_auth(first)).unwrap(); + let (_, first) = tokens.next().await.expect("the announce's token"); + + let second = pair.client.auth().add("second").await.expect("kept for requests"); + broadcast.announce(origin::Route::default().with_auth(second)).unwrap(); + moq_net_sim::sleep(Duration::from_secs(1)).await; + drop(first); + moq_net_sim::sleep(Duration::from_secs(1)).await; + assert_eq!(pair.client_transport.close_reason(), None); + }) + .await + .expect("timed out"); +} + +/// A pair where the client declines MoQ Auth, so every token the server sees rode a +/// request, and the server declines MoQ Solicit, so the client announces with /// PUBLISH_NAMESPACE rather than answering a SUBSCRIBE_NAMESPACE. +async fn connect_route_auth(version: &'static str) -> (Pair, origin::Producer, origin::Producer) { + let declined = |f: fn(&mut moq_net::setup::Extensions)| { + let mut extensions = moq_net::setup::Extensions::default(); + f(&mut extensions); + Some(extensions) + }; + let client_origin = produce_origin(2); + let server_origin = produce_origin(1); + let pair = connect(Options { + version: Some(version), + client_publish: Some(client_origin.clone()), + server_subscribe: Some(server_origin.clone()), + server_requests: true, + client_extensions: declined(|e| e.auth = false), + server_extensions: declined(|e| e.solicit = false), + ..Default::default() + }) + .await; + (pair, client_origin, server_origin) +} + +/// A route's auth reaches the peer's acceptor on the announcement, and swapping it +/// renews the announcement with the new token, keeping it live. async fn a_route_auth_renews_its_announce(version: &'static str) { within(async { - let declined = |f: fn(&mut moq_net::setup::Extensions)| { - let mut extensions = moq_net::setup::Extensions::default(); - f(&mut extensions); - Some(extensions) - }; - let client_origin = produce_origin(2); - let server_origin = produce_origin(1); - let mut pair = connect(Options { - version: Some(version), - client_publish: Some(client_origin.clone()), - server_subscribe: Some(server_origin.clone()), - server_requests: true, - client_extensions: declined(|e| e.auth = false), - server_extensions: declined(|e| e.solicit = false), - ..Default::default() - }) - .await; + let (mut pair, client_origin, server_origin) = connect_route_auth(version).await; let mut tokens = serve(pair.requests.take().unwrap(), |_| Some(Grant::all())); let first = pair.client.auth().add("first").await.expect("kept for requests"); let broadcast = client_origin.create_broadcast("room/x").unwrap(); - broadcast - .announce(origin::Route::default().with_auth(first)) - .unwrap(); + broadcast.announce(origin::Route::default().with_auth(first)).unwrap(); let (token, _first) = tokens.next().await.expect("the announce's token"); assert_eq!(token, b"first"); wait_announced(&server_origin.consume(), "room/x", true).await; let second = pair.client.auth().add("second").await.expect("kept for requests"); - broadcast - .announce(origin::Route::default().with_auth(second)) - .unwrap(); + broadcast.announce(origin::Route::default().with_auth(second)).unwrap(); let (token, _second) = tokens.next().await.expect("the renewal's token"); assert_eq!(token, b"second"); wait_announced(&server_origin.consume(), "room/x", true).await; @@ -644,9 +695,9 @@ async fn a_refused_token_reports_the_code(version: &'static str) { } }); - let err = pair.client.auth().add("forged").await.err().expect("refused"); + let err = pair.client.auth().add("forged").await.expect_err("refused"); assert!(matches!(err, Error::Session(SessionError::Unauthorized)), "{err:?}"); - let err = pair.client.auth().add("opaque").await.err().expect("refused"); + let err = pair.client.auth().add("opaque").await.expect_err("refused"); assert!(matches!(err, Error::Unsupported), "{err:?}"); }) .await @@ -761,7 +812,7 @@ async fn a_reset_auth_stream_reports_unsupported(version: &'static str) { .await; granted(&pair.client).await; - let err = pair.client.auth().add("token").await.err().expect("no acceptor"); + let err = pair.client.auth().add("token").await.expect_err("no acceptor"); assert!(matches!(err, Error::Unsupported), "{err:?}"); assert_eq!(pair.client_transport.close_reason(), None); }) @@ -940,7 +991,7 @@ async fn an_unrepresentable_grant_is_unsupported(version: &'static str) { assert_eq!(granted(&pair.client).await, grant(&["a"], &[])); for token in ["exact", "mixed", "wildcard"] { - let err = pair.client.auth().add(token).await.err().expect("not representable"); + let err = pair.client.auth().add(token).await.expect_err("not representable"); assert!(matches!(err, Error::Unsupported), "{token}: {err:?}"); } // The other token is untouched, and so is the session. @@ -1018,7 +1069,7 @@ async fn a_grant_too_large_for_one_message_is_unsupported(version: &'static str) let (_, _setup) = issued.next().await.unwrap(); assert_eq!(granted(&pair.client).await, grant(&["a"], &[])); - let err = pair.client.auth().add("huge").await.err().expect("too large"); + let err = pair.client.auth().add("huge").await.expect_err("too large"); assert!(matches!(err, Error::Unsupported), "{err:?}"); // No AUTH_OK for it reached the wire: the union is still just the setup grant. assert_eq!(pair.client.auth().grant().peek(), Some(grant(&["a"], &[]))); diff --git a/rs/moq-net/tests/extensions.rs b/rs/moq-net/tests/extensions.rs index 3ce750056dc..406c893eec3 100644 --- a/rs/moq-net/tests/extensions.rs +++ b/rs/moq-net/tests/extensions.rs @@ -9,7 +9,7 @@ mod support; use std::time::Duration; -use moq_net::{Error, Hop, Version, origin, setup::Extensions}; +use moq_net::{Hop, Version, origin, setup::Extensions}; use support::harness::{MockConnectOptions, MockPair, connect_mock}; /// Long enough, in virtual time, for anything in flight to reach the far side. diff --git a/rs/moq-relay/benches/auth.rs b/rs/moq-relay/benches/auth.rs index 4345bafb3ea..4d1ed48c6e2 100644 --- a/rs/moq-relay/benches/auth.rs +++ b/rs/moq-relay/benches/auth.rs @@ -16,7 +16,7 @@ fn grant(prefix: &str) -> Grant { struct Fixture { _connections: Vec, - _tokens: Vec, + _tokens: Vec, producers: Vec, selected: usize, watch: moq_tokio::connection::auth::Watch, @@ -149,7 +149,7 @@ fn refresh(c: &mut Criterion) { /// oldest waiting token, and presents one more so the queue keeps its depth. struct Queue { _connection: moq_tokio::Connection, - auth: moq_tokio::connection::auth::Auth, + auth: moq_tokio::connection::auth::Handle, admissions: auth::Admissions, held: std::collections::VecDeque, presented: usize, diff --git a/rs/moq-tokio/src/connection.rs b/rs/moq-tokio/src/connection.rs index 132ff00e734..49b6bcf3f9c 100644 --- a/rs/moq-tokio/src/connection.rs +++ b/rs/moq-tokio/src/connection.rs @@ -1,7 +1,6 @@ //! The reconnect loop around a session, and the [`Monitor`] that observes it. pub mod auth; -pub use auth::Auth; use std::task::{Poll, ready}; use std::time::Duration; @@ -1222,8 +1221,8 @@ impl Connection { } /// Present tokens that survive session replacement and observe the live grant union. - pub fn auth(&self) -> Auth { - Auth { + pub fn auth(&self) -> auth::Handle { + auth::Handle { connection: self.state.clone(), pacing: self.pacing, } diff --git a/rs/moq-tokio/src/connection/auth.rs b/rs/moq-tokio/src/connection/auth.rs index 97a7a2b229a..1ad8d2a4b30 100644 --- a/rs/moq-tokio/src/connection/auth.rs +++ b/rs/moq-tokio/src/connection/auth.rs @@ -11,12 +11,12 @@ use crate::{Error, abort::AbortOnDrop}; /// Present session tokens across every session of a connection. #[derive(Clone)] -pub struct Auth { +pub struct Handle { pub(super) connection: kio::Consumer, pub(super) pacing: Pacing, } -impl Auth { +impl Handle { /// Watch the current session's union, clearing it while disconnected. pub fn grant(&self) -> Watch { Watch { @@ -26,7 +26,7 @@ impl Auth { } /// Present a token now and on later sessions until its handle is dropped or refused. - pub async fn add(&self, token: impl Into) -> crate::Result { + pub async fn add(&self, token: impl Into) -> crate::Result { let producer = kio::Producer::::default(); let state = producer.consume(); let connection = self.connection.clone(); @@ -46,21 +46,21 @@ impl Auth { } })); answer.await.map_err(|_| Error::from(moq_net::Error::Cancel))??; - Ok(Token { _task: task, state }) + Ok(Auth { _task: task, state }) } } /// A presented token; dropping it withdraws it from live and draining sessions. -pub struct Token { +pub struct Auth { _task: AbortOnDrop, state: kio::Consumer, } -impl Token { +impl Auth { /// Watch this token's grant on the current session. pub fn grant(&self) -> Watch { Watch { - source: Source::Token(self.state.clone()), + source: Source::Auth(self.state.clone()), last: None, } } @@ -84,7 +84,7 @@ struct Granted { #[derive(Clone)] enum Source { Connection(kio::Consumer), - Token(kio::Consumer), + Auth(kio::Consumer), } /// Observe a grant as tokens change and the connection replaces its session. @@ -108,7 +108,7 @@ impl Watch { .as_ref() .and_then(|session| session.auth().grant().peek()) } - Source::Token(state) => { + Source::Auth(state) => { if state.poll_closed(&kio::Waiter::noop()).is_ready() { return None; } @@ -136,7 +136,7 @@ impl Watch { Ok(grant) => Ok(grant), Err(state) => Err(terminal(&state)), }, - Source::Token(state) => match ready!(state.poll(waiter, |state| { + Source::Auth(state) => match ready!(state.poll(waiter, |state| { if state.grant != self.last { Poll::Ready(state.grant.clone()) } else { @@ -274,7 +274,7 @@ async fn present( } // A migrated session may still be serving its streams during GOAWAY drain. // Keep its grant until that session ends or the application drops this token. - draining.retain(|(session, _): &(moq_net::Session, auth::Token)| session.closed().now_or_never().is_none()); + draining.retain(|(session, _): &(moq_net::Session, auth::Auth)| session.closed().now_or_never().is_none()); draining.push((session, token)); } } @@ -419,8 +419,8 @@ mod tests { drop(token); } - fn retained(connection: &kio::Producer) -> Auth { - Auth { + fn retained(connection: &kio::Producer) -> Handle { + Handle { connection: connection.consume(), pacing: Pacing::new(&super::super::Backoff::default()), } diff --git a/rs/moq-tokio/src/lib.rs b/rs/moq-tokio/src/lib.rs index 1dc43f9694a..a1566a9e838 100644 --- a/rs/moq-tokio/src/lib.rs +++ b/rs/moq-tokio/src/lib.rs @@ -82,7 +82,7 @@ fn qmux_config() -> qmux::Config { // new `pub` item in these modules doesn't silently join it. pub use client::Client; pub use connect::{Addrs, ConnectError}; -pub use connection::{Auth, Backoff, Connection, Redirect, Status}; +pub use connection::{Backoff, Connection, Redirect, Status}; pub use error::{Error, Result}; pub use log::{Log, RedactedUrl}; #[cfg(feature = "_transport")] From 66fd66c1c34c9ff67f3de527eac2d8778c625412 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 16 Oct 2026 11:00:53 -0700 Subject: [PATCH 06/14] feat(js)!: a route carries its token on moq-transport announcements Mirrors Route::auth in js/net: Route.auth names a token from auth.add(), which a session carries on its PUBLISH_NAMESPACE only when it issued it. Swapping it renews the request with REQUEST_UPDATE from draft 17 and makes it again before; moq-lite advertises the route unnamed. On moq-transport auth.add() now keeps a token for requests even without MoQ Auth. Co-Authored-By: Claude Opus 5.5 --- doc/lib/js/net.md | 2 +- js/net/src/auth.test.ts | 15 +++- js/net/src/auth.ts | 10 ++- js/net/src/auth_session.ts | 45 +++++++++- js/net/src/broadcast.ts | 9 +- js/net/src/hop.ts | 22 ++++- js/net/src/ietf/connection.ts | 2 + js/net/src/ietf/publish_namespace.ts | 19 +++- js/net/src/ietf/publisher.test.ts | 130 ++++++++++++++++++++++++++- js/net/src/ietf/publisher.ts | 104 ++++++++++++++++----- js/net/src/ietf/token.test.ts | 23 ++++- js/net/src/ietf/token.ts | 18 +++- js/net/src/lite/publisher.ts | 7 +- js/net/src/origin.ts | 24 ++++- js/net/src/wire.ts | 2 +- quest/m1/auth/request-update.md | 6 +- rs/moq-net/src/auth.rs | 2 +- rs/moq-net/src/model/origin.rs | 2 +- 18 files changed, 385 insertions(+), 57 deletions(-) diff --git a/doc/lib/js/net.md b/doc/lib/js/net.md index cb895538573..3aa25ae614a 100644 --- a/doc/lib/js/net.md +++ b/doc/lib/js/net.md @@ -45,7 +45,7 @@ broadcast.announce(); - **Timing is per track, with no default.** A track that declares a `timescale` carries a `timestamp` on every frame; one without is [untimed](/concept/moq-lite#subscriptions) and its frames carry none. Omitting it means untimed, as in Rust. Nothing stamps a frame for you: the publisher passes the timestamp, and the `Timed` type (`{ value, at }`) carries one into the `@moq/json` and `@moq/flate` producers. - **Subscriber staleness is `maxDelay`.** It counts from a group's successor, by media time and by wall clock, whichever reaches it first. Passing the old `maxAge` key throws a `TypeError` naming `maxDelay`. Publisher retention is the separate `Track.Info.maxAge`. - **Hidden paths** stay out of discovery unless the announce request opts in. See [hidden broadcasts](/concept/moq-lite#hidden-broadcasts). -- **Authorization is in band.** On moq-lite 07 (offered only when `moq-lite-07-wip` is listed in `webtransport.protocols`; the WebSocket fallback cannot offer it) and on moq-transport draft-17+ with the [MoQ Auth extension](/draft/moq-auth), `auth.grant` watches what the relay lets this side publish and subscribe to, and `auth.add(token)` presents another token without reconnecting. Publishing outside the grant closes the connection with `SessionCode.Unauthorized`, naming the path. On moq-lite, a subscription the grant stops covering resets with `StreamCode.Unauthorized` and the session stays up. +- **Authorization is in band.** On moq-lite 07 (offered only when `moq-lite-07-wip` is listed in `webtransport.protocols`; the WebSocket fallback cannot offer it) and on moq-transport draft-17+ with the [MoQ Auth extension](/draft/moq-auth), `auth.grant` watches what the relay lets this side publish and subscribe to, and `auth.add(token)` presents another token without reconnecting. Publishing outside the grant closes the connection with `SessionCode.Unauthorized`, naming the path. On moq-lite, a subscription the grant stops covering resets with `StreamCode.Unauthorized` and the session stays up. On moq-transport, `auth.add(token)` keeps the token even without the extension, and a broadcast announced with it (`broadcast.announce({ auth: token })`) carries it on that announcement's `PUBLISH_NAMESPACE`; announcing again with another renews the request. - **A graceful close waits.** `await connection.close()` withdraws announcements and drains for up to one second. Application delivery requires explicit `moq-lite-07-wip`; older Lite versions wait only for transport ACKs. IETF media streams are not drained. `abort()` ends immediately. Examples: diff --git a/js/net/src/auth.test.ts b/js/net/src/auth.test.ts index 43c3c3933a0..05069af5430 100644 --- a/js/net/src/auth.test.ts +++ b/js/net/src/auth.test.ts @@ -367,10 +367,21 @@ test("lite-07 carries literal and wildcard grants exactly", async () => { server.abort(); }); -test.each([Lite.ALPN_05, Lite.ALPN_06, Ietf.ALPN.DRAFT_16])("%s has no grant", async (protocol) => { +// moq-transport keeps the token for the requests that name it, with no grant of its own. +test.each([ + [Lite.ALPN_05, false], + [Lite.ALPN_06, false], + [Ietf.ALPN.DRAFT_16, true], +])("%s has no grant, keeping a token for requests: %p", async (protocol, kept) => { const { client, server } = await connect({ publish: new OriginProducer(), protocol }); expect(client.auth.grant.peek()).toBeUndefined(); - await expect(client.auth.add("token")).rejects.toBeInstanceOf(Unsupported); + if (kept) { + const token = await client.auth.add("token"); + expect(token.grant.peek()).toBeUndefined(); + expect(client.auth.grant.peek()).toBeUndefined(); + } else { + await expect(client.auth.add("token")).rejects.toBeInstanceOf(Unsupported); + } client.abort(); server.abort(); }); diff --git a/js/net/src/auth.ts b/js/net/src/auth.ts index 13920b5ecbf..f94944eedfd 100644 --- a/js/net/src/auth.ts +++ b/js/net/src/auth.ts @@ -5,9 +5,10 @@ * sides negotiate MoQ Auth, presents the credential its connection already carried (the * URL, or nothing) right after setup, and learns the {@link Grant} it earned. Older * versions, and peers that do not negotiate it, carry no AUTH exchange: the grant stays - * undefined and {@link Auth.add} rejects with {@link Unsupported}. {@link Auth.grant} is - * the union of every token this side presented, and {@link Auth.add} presents another - * without reconnecting. Mirrors the Rust `moq_net::auth`. + * undefined. There {@link Auth.add} rejects with {@link Unsupported} on moq-lite, and on + * moq-transport keeps the token, with no grant, for the announcements whose route names it. + * {@link Auth.grant} is the union of every token this side presented, and {@link Auth.add} + * presents another without reconnecting. Mirrors the Rust `moq_net::auth`. * * @module */ @@ -101,7 +102,8 @@ export interface Auth { /** * Present another token, resolving once the peer answers it. Rejects with * {@link Unsupported} when the session or the peer takes no tokens in band, or with the - * peer's refusal as an `Error.Session`. + * peer's refusal as an `Error.Session`. On moq-transport without AUTH it resolves at + * once with no grant: only a request whose route names it carries it. */ add(token: string | Uint8Array): Promise; diff --git a/js/net/src/auth_session.ts b/js/net/src/auth_session.ts index f352951d83c..5b98d3e36bb 100644 --- a/js/net/src/auth_session.ts +++ b/js/net/src/auth_session.ts @@ -115,7 +115,9 @@ class Presented implements Token { if (this.withdrawn) return; this.withdrawn = true; // The stream's loop notices and ends the token; one still opening checks on arrival. - this.stream?.abort(cancel()); + // One without a stream to come (carried only by requests) ends here. + if (this.stream) this.stream.abort(cancel()); + else if (this.isAnswered) this.end(null); } } @@ -258,6 +260,11 @@ class RequestQueue implements Requests { export interface AuthSessionProps { /** How this session's wire carries the exchange, or undefined when it carries none. */ wire?: AuthWire; + /** + * Whether a request can carry a token (moq-transport), so {@link AuthSession.add} keeps one + * the wire cannot present, for the announcements that name it. + */ + requests?: boolean; /** What the default acceptor grants the peer's connection credential. */ peerGrant: Grant; } @@ -272,7 +279,10 @@ export class AuthSession implements AuthApi { /** The peer sent a malformed AUTH message; the owning connection closes the session. */ readonly violated = new Once(); #wire?: AuthWire; + #requests: boolean; #peerGrant: Grant; + // Every token this session issued, so a request carries only its own. + #issued = new WeakSet(); #union = new Signal(undefined); // The peer replied to some token, so the union is known even when empty. @@ -287,8 +297,9 @@ export class AuthSession implements AuthApi { // wins, however quickly the peer's first token arrives. #decided = nextTask(); - constructor({ wire, peerGrant }: AuthSessionProps) { + constructor({ wire, requests, peerGrant }: AuthSessionProps) { this.#wire = wire; + this.#requests = requests ?? false; this.#peerGrant = peerGrant; // Present the connection's own credential right away, so both sides learn their @@ -306,13 +317,30 @@ export class AuthSession implements AuthApi { } async add(token: string | Uint8Array): Promise { - if (!this.#wire || this.#closed) throw new Unsupported(); + if (this.#closed || (!this.#wire && !this.#requests)) throw new Unsupported(); const bytes = typeof token === "string" ? new TextEncoder().encode(token) : token; + if (!this.#wire) { + // Only a request carries it, and the peer judges it there: no grant to wait for. + const presented = new Presented(bytes, false); + presented.answer(); + this.#issued.add(presented); + return presented; + } const presented = this.#present(this.#wire, bytes, false); await presented.answered; return presented; } + /** + * The bytes a request carries for `token`: undefined for none, for one another session + * issued, or for one that ended. + */ + carried(token: Token | undefined): Uint8Array | undefined { + if (!this.#requests || !(token instanceof Presented) || !this.#issued.has(token) || token.ended) + return undefined; + return token.token; + } + requests(): Requests { if (this.#acceptor !== "undecided") throw new Error("auth requests already taken or answered by default"); const queue = new RequestQueue(); @@ -378,6 +406,7 @@ export class AuthSession implements AuthApi { #present(wire: AuthWire, token: Uint8Array, setup: boolean): Presented { const presented = new Presented(token, setup); this.#tokens.add(presented); + this.#issued.add(presented); if (setup) this.#setupPending.update((n) => n + 1); void this.#run(wire, presented); return presented; @@ -385,6 +414,7 @@ export class AuthSession implements AuthApi { async #run(wire: AuthWire, token: Presented) { let result: Error | null = null; + let predates = false; try { const stream = await wire.present(token.token); token.stream = stream; @@ -433,6 +463,7 @@ export class AuthSession implements AuthApi { } else if (!token.isAnswered && err instanceof StreamError) { // A peer that predates AUTH resets a stream type it does not know. result = new Unsupported(); + predates = true; } else { result = error(err); if (result instanceof ProtocolViolation && !this.#closed && this.violated.peek() === undefined) { @@ -442,6 +473,14 @@ export class AuthSession implements AuthApi { } } + // A peer that turned out not to speak AUTH leaves a token for requests to carry. + if (predates && this.#requests) { + token.stream = undefined; + this.#answered(token); + this.#tokens.delete(token); + return; + } + // A token that ends unanswered counts as answered for enforcement: its refusal is // the reply. const unanswered = !token.isAnswered; diff --git a/js/net/src/broadcast.ts b/js/net/src/broadcast.ts index c83f7459b84..804342f7d2e 100644 --- a/js/net/src/broadcast.ts +++ b/js/net/src/broadcast.ts @@ -459,7 +459,14 @@ export class Producer { * origin, or if the broadcast is already closed. */ announce( - route: Route | { epoch?: Route["epoch"]; hops?: Route["hops"]; cost?: Route["cost"] } = Route.default, + route: + | Route + | { + epoch?: Route["epoch"]; + hops?: Route["hops"]; + cost?: Route["cost"]; + auth?: Route["auth"]; + } = Route.default, ): void { if (this.#state.closed.peek() !== undefined) { throw new Error("broadcast is closed"); diff --git a/js/net/src/hop.ts b/js/net/src/hop.ts index 560c8583531..e465c305a92 100644 --- a/js/net/src/hop.ts +++ b/js/net/src/hop.ts @@ -8,6 +8,7 @@ * @module */ import * as z from "zod/mini"; +import type { Token } from "./auth.ts"; import type * as Epoch from "./epoch.ts"; /** @@ -95,6 +96,15 @@ export interface Route { hops: Hop[]; /** What pulling content via this route costs; lower wins. */ cost: Cost; + /** + * The token the request advertising this route carries, from `auth.add()`, or undefined + * for the session's own credential. Only the session that issued it carries it, as the + * token on its moq-transport PUBLISH_NAMESPACE: any other session, an answer to a + * solicited SUBSCRIBE_NAMESPACE, and moq-lite advertise the route unnamed. Swapping it + * renews the request with REQUEST_UPDATE from draft 17, or ends and makes it again + * before. Local: a relay never forwards it. Mirrors `Route::auth` in rs/moq-net. + */ + auth?: Token; } /** Constructors and helpers for {@link Route}. */ @@ -103,11 +113,16 @@ export const Route = { default: { hops: [], cost: Cost.zero } as Route, /** Fill route defaults and saturate the static price at the wire ceiling. */ - normalize(route: Route | { epoch?: Epoch.Valid; hops?: readonly Hop[]; cost?: Cost } = {}): Route { + normalize(route: Route | { epoch?: Epoch.Valid; hops?: readonly Hop[]; cost?: Cost; auth?: Token } = {}): Route { const cost = route.cost ?? Cost.zero; if (typeof cost !== "bigint" || cost < 0n) throw new RangeError("route cost must be a non-negative bigint"); const max = 2n ** 62n - 1n; - return { epoch: route.epoch, hops: route.hops ? [...route.hops] : [], cost: cost > max ? max : cost }; + return { + epoch: route.epoch, + hops: route.hops ? [...route.hops] : [], + cost: cost > max ? max : cost, + auth: route.auth, + }; }, }; @@ -121,13 +136,14 @@ export function isAnonymous(route: Route): boolean { return route.hops.includes(UNKNOWN_HOP); } -/** Whether two routes name the same epoch, hop chain, and cost. */ +/** Whether two routes name the same epoch, hop chain, cost, and auth. */ export function routesEqual(a: Route | undefined, b: Route | undefined): boolean { if (a === b) return true; if (!a || !b) return false; return ( a.epoch === b.epoch && a.cost === b.cost && + a.auth === b.auth && a.hops.length === b.hops.length && a.hops.every((hop, i) => hop === b.hops[i]) ); diff --git a/js/net/src/ietf/connection.ts b/js/net/src/ietf/connection.ts index f8ca1449add..f85b681d577 100644 --- a/js/net/src/ietf/connection.ts +++ b/js/net/src/ietf/connection.ts @@ -191,6 +191,7 @@ export class Connection implements Established { const session = this.#session; this.#auth = new AuthSession({ wire: auth && authSupported(version) ? new IetfAuthWire(session) : undefined, + requests: true, peerGrant: { publish: new Path.Patterns([Path.Pattern.all()]), subscribe: new Path.Patterns(publish ? [Path.Pattern.all()] : []), @@ -210,6 +211,7 @@ export class Connection implements Established { hidden, cluster, grant: this.#auth.grant, + auth: this.#auth, ready: this.#auth.setupAnswered(), }); this.#solicit = solicit; diff --git a/js/net/src/ietf/publish_namespace.ts b/js/net/src/ietf/publish_namespace.ts index 5c9c6981dde..19b80017b73 100644 --- a/js/net/src/ietf/publish_namespace.ts +++ b/js/net/src/ietf/publish_namespace.ts @@ -5,7 +5,7 @@ import * as Cluster from "./cluster.ts"; import * as Message from "./message.ts"; import * as Namespace from "./namespace.ts"; import { Parameters } from "./parameters.ts"; -import { MalformedAuthToken } from "./token.ts"; +import { MalformedAuthToken, tokenIntoRequest } from "./token.ts"; import { type IetfVersion, Version } from "./version.ts"; // In draft-14, ANNOUNCE is renamed to PUBLISH_NAMESPACE @@ -20,14 +20,19 @@ export class PublishNamespace { * appear on the wire at all. */ cluster?: Cluster.Advert; + /** The AUTHORIZATION TOKEN to send, a value agreed out of band. Decoding leaves it unset. */ + token?: Uint8Array; + constructor({ requestId, trackNamespace, cluster, - }: { requestId: bigint; trackNamespace: Path.Valid; cluster?: Cluster.Advert }) { + token, + }: { requestId: bigint; trackNamespace: Path.Valid; cluster?: Cluster.Advert; token?: Uint8Array }) { this.requestId = requestId; this.trackNamespace = trackNamespace; this.cluster = cluster; + this.token = token; } async #encode(w: Writer, version: IetfVersion): Promise { @@ -37,6 +42,7 @@ export class PublishNamespace { } await Namespace.encode(w, this.trackNamespace); const params = this.cluster ? Cluster.intoParams(this.cluster) : new Parameters(); + if (this.token) tokenIntoRequest(params, this.token, version); await params.encode(w, version); } @@ -87,19 +93,24 @@ export class PublishNamespaceUpdate { /** Draft-16 only: the PUBLISH_NAMESPACE this updates, which later drafts name by stream. */ namespaceRequestId?: bigint; update: Cluster.Update; + /** A new AUTHORIZATION TOKEN, renewing the request's. Decoding leaves it unset. */ + token?: Uint8Array; constructor({ requestId, namespaceRequestId, update, + token, }: { requestId: bigint; namespaceRequestId?: bigint; update: Cluster.Update; + token?: Uint8Array; }) { this.requestId = requestId; this.namespaceRequestId = namespaceRequestId; this.update = update; + this.token = token; } async #encode(w: Writer, version: IetfVersion): Promise { @@ -114,7 +125,9 @@ export class PublishNamespaceUpdate { if (version === Version.DRAFT_17) { await w.u62(0n); // required_request_id_delta (draft-17 only, removed in draft-18 per #1615) } - await Cluster.updateIntoParams(this.update).encode(w, version); + const params = Cluster.updateIntoParams(this.update); + if (this.token) tokenIntoRequest(params, this.token, version); + await params.encode(w, version); } async encode(w: Writer, version: IetfVersion): Promise { diff --git a/js/net/src/ietf/publisher.test.ts b/js/net/src/ietf/publisher.test.ts index 234dfbfcb5e..c7e95e47178 100644 --- a/js/net/src/ietf/publisher.test.ts +++ b/js/net/src/ietf/publisher.test.ts @@ -1,7 +1,7 @@ import { expect, jest, spyOn, test } from "bun:test"; import { Milli } from "@moq/clock"; import { Signal } from "@moq/signals"; -import type { Grant } from "../auth.ts"; +import type { Grant, Token } from "../auth.ts"; import { Producer as BroadcastProducer } from "../broadcast.ts"; import * as Epoch from "../epoch.ts"; import { error, ProtocolViolation, StreamCode } from "../error.ts"; @@ -120,11 +120,13 @@ function publisher( session, cluster, grant, + auth, }: { requiresSolicitation?: boolean; session?: Session; cluster?: Cluster.Hops; grant?: Signal; + auth?: Tokens; } = {}, ): { pub: Publisher; origin: OriginProducer } { const origin = new OriginProducer(); @@ -137,6 +139,7 @@ function publisher( requiresSolicitation, cluster, grant, + auth, }), origin, }; @@ -1343,12 +1346,12 @@ async function readUpdate(stream: Stream): Promise { } /** A clustered publisher on fake time, with every stream it opens collected. */ -function clustered(requiresSolicitation = false) { +function clustered(requiresSolicitation = false, auth?: Tokens) { jest.useFakeTimers(); const self: Hop = HopSchema.parse(7n); const peer: Hop = HopSchema.parse(9n); const pair = createMockTransportPair(ALPN.DRAFT_19); - const { pub, origin } = publisher(pair.server, { cluster: { self, peer }, requiresSolicitation }); + const { pub, origin } = publisher(pair.server, { cluster: { self, peer }, requiresSolicitation, auth }); const streams = accepted(pair.client); const close = () => { origin.close(); @@ -1490,6 +1493,127 @@ function republish(origin: OriginProducer, old: BroadcastProducer) { old.close(); } +/** The tokens one session issued, each carried as its own bytes. */ +class Tokens { + #issued = new Map(); + + issue(value: string): Token { + const token: Token = { + grant: new Signal(undefined), + closed: new Promise(() => {}), + close() {}, + }; + this.#issued.set(token, new TextEncoder().encode(value)); + return token; + } + + carried(token: Token | undefined): Uint8Array | undefined { + return token && this.#issued.get(token); + } +} + +/** Record the token each PUBLISH_NAMESPACE and REQUEST_UPDATE carries, as text. */ +function carriedTokens() { + const sent: (string | undefined)[] = []; + const text = (token?: Uint8Array) => (token ? new TextDecoder().decode(token) : undefined); + const advertise = PublishNamespace.prototype.encode; + const update = PublishNamespaceUpdate.prototype.encode; + const spies = [ + spyOn(PublishNamespace.prototype, "encode").mockImplementation(function (this: PublishNamespace, w, v) { + sent.push(text(this.token)); + return advertise.call(this, w, v); + }), + spyOn(PublishNamespaceUpdate.prototype, "encode").mockImplementation(function ( + this: PublishNamespaceUpdate, + w, + v, + ) { + sent.push(text(this.token)); + return update.call(this, w, v); + }), + ]; + return { + sent, + restore: () => { + for (const spy of spies) spy.mockRestore(); + }, + }; +} + +/** + * A route's token rides its PUBLISH_NAMESPACE, and swapping it renews the request in + * place: one REQUEST_UPDATE carrying only the new token. Mirrors the Rust publisher. + */ +test("swapping a route's token is one REQUEST_UPDATE carrying it", async () => { + const tokens = new Tokens(); + const { pub, origin, streams, close } = clustered(false, tokens); + const { sent, restore } = carriedTokens(); + try { + const broadcast = origin.createBroadcast(Path.from("mine")); + broadcast.announce({ hops: [VIA], cost: 4n, auth: tokens.issue("first") }); + void pub.runPublishNamespaces(); + + const stream = await take(streams); + await acceptClustered(stream); + expect(sent).toEqual(["first"]); + + broadcast.announce({ hops: [VIA], cost: 4n, auth: tokens.issue("second") }); + expect(await readUpdate(stream)).toEqual({ hops: undefined, cost: undefined }); + await acceptPublishNamespace(stream); + expect(sent).toEqual(["first", "second"]); + expect(streams).toHaveLength(0); + } finally { + restore(); + close(); + } +}); + +/** A token another session issued is never carried, so swapping between two sends nothing. */ +test("a foreign route token is not carried", async () => { + const foreign = new Tokens(); + const { pub, origin, streams, close } = clustered(false, new Tokens()); + const { sent, restore } = carriedTokens(); + try { + const broadcast = origin.createBroadcast(Path.from("mine")); + broadcast.announce({ hops: [VIA], cost: 4n, auth: foreign.issue("first") }); + void pub.runPublishNamespaces(); + + const stream = await take(streams); + await acceptClustered(stream); + broadcast.announce({ hops: [VIA], cost: 4n, auth: foreign.issue("second") }); + const more = watch(stream.reader.u53()); + await flush(); + expect(more.state).toBe("pending"); + expect(sent).toEqual([undefined]); + } finally { + restore(); + close(); + } +}); + +/** REQUEST_UPDATE cannot name a token on a request that had none, so the request restarts. */ +test("naming a token on an unnamed request makes it again", async () => { + const tokens = new Tokens(); + const { pub, origin, streams, close } = clustered(false, tokens); + const { sent, restore } = carriedTokens(); + try { + const broadcast = origin.createBroadcast(Path.from("mine")); + broadcast.announce({ hops: [VIA], cost: 4n }); + void pub.runPublishNamespaces(); + + const old = await take(streams); + await acceptClustered(old); + broadcast.announce({ hops: [VIA], cost: 4n, auth: tokens.issue("first") }); + + expect(await old.reader.done().catch(error)).toMatchObject({ code: StreamCode.Cancel }); + await acceptClustered(await take(streams)); + expect(sent).toEqual([undefined, "first"]); + } finally { + restore(); + close(); + } +}); + test("a republish withdraws the PUBLISH_NAMESPACE and advertises again", async () => { const { pub, origin, streams, close } = clustered(); try { diff --git a/js/net/src/ietf/publisher.ts b/js/net/src/ietf/publisher.ts index 0dc1360a0c9..dff40964321 100644 --- a/js/net/src/ietf/publisher.ts +++ b/js/net/src/ietf/publisher.ts @@ -1,6 +1,6 @@ import { Instant, Milli, Nano, now, sleep } from "@moq/clock"; import { type Dispose, type Getter, race, Signal } from "@moq/signals"; -import type { Grant } from "../auth.ts"; +import type { Grant, Token } from "../auth.ts"; import { enforceGrant } from "../auth_session.ts"; import type * as broadcast from "../broadcast.ts"; import { Withdrawal } from "../connection/withdrawal.ts"; @@ -102,10 +102,15 @@ function change( return { from, to }; } -/** A held advertisement's cluster parameters, and the ones the peer should hold instead. */ +/** + * What changed for a namespace the peer holds: its cluster parameters (from what it holds + * to what it should), its token, or both. + */ interface Reprice { - from: Cluster.Advert; - to: Cluster.Advert; + from?: Cluster.Advert; + to?: Cluster.Advert; + /** The token the request should carry instead, when it changed. */ + token?: Token; } /** Whether two hop paths list the same hops in the same order. */ @@ -143,7 +148,7 @@ type Refused = "never" | number; type Answer = "held" | "dropped" | Refused; /** The open PUBLISH_NAMESPACE request per advertised path. */ -type Requests = Map; +type Requests = Map; /** What one announce loop knows about the peer, keyed the way that loop names namespaces. */ interface Namespaces { @@ -157,6 +162,11 @@ interface Namespaces { offered: Map; } +/** The bytes a request carries for a token this session issued. */ +interface Carrier { + carried(token: Token | undefined): Uint8Array | undefined; +} + /** Where one announce loop sends its advertisements. */ interface Target { /** Advertise a namespace the peer does not hold. */ @@ -167,6 +177,12 @@ interface Target { /** Withdraw a namespace the peer holds. */ withdraw(key: Path.Valid): Promise; + + /** The token the peer holds a namespace under, or undefined when its advertisement carries none. */ + held(key: Path.Valid): Token | undefined; + + /** The token an advertisement naming `auth` carries: only a request can carry one. */ + carry(auth: Token | undefined): Token | undefined; } /** What {@link Publisher.runGroup} needs to serve one group. */ @@ -289,6 +305,9 @@ export class Publisher { // advertised before the grant it would be checked against. #ready: Promise; + // The bytes a request carries for a route's token, for only the tokens this session issued. + #auth?: Carrier; + /** * Creates a new Publisher instance. * @@ -302,6 +321,7 @@ export class Publisher { hidden = false, cluster, grant = new Signal(undefined), + auth, ready = Promise.resolve(), limits = LIMITS, }: { @@ -319,6 +339,8 @@ export class Publisher { cluster?: Cluster.Hops; /** The union of our tokens' grants (MoQ Auth), which bounds what we publish. */ grant?: Getter; + /** This session's tokens, which a route names for its request to carry. */ + auth?: Carrier; /** Resolves once the setup tokens are answered (MoQ Auth). */ ready?: Promise; /** How many live requests the peer may hold. */ @@ -328,6 +350,7 @@ export class Publisher { this.#subscriptions = new Slots(limits.subscriptions, { quic, reason: "too many subscriptions" }); this.#fetches = new Slots(limits.fetches, { quic, reason: "too many fetches" }); this.#grant = grant; + this.#auth = auth; this.#ready = ready; this.#session = session; const origin = publish && wireOf(publish); @@ -1203,14 +1226,17 @@ export class Publisher { }; const target: Target = legacy ? { - advertise: (suffix, snap) => - this.#advertise(Path.join(prefix, suffix), requests, clusterFor(this.#advert, snap.route)), + advertise: (suffix, snap) => this.#advertise(Path.join(prefix, suffix), requests, snap.route), update: (suffix, _snap, reprice) => this.#update(Path.join(prefix, suffix), requests, reprice), withdraw: (suffix) => this.#withdraw(Path.join(prefix, suffix), requests), + held: (suffix) => requests.get(Path.join(prefix, suffix))?.auth, + carry: (auth) => this.#carry(auth), } : { advertise: namespace, update: namespace, + held: () => undefined, + carry: () => undefined, withdraw: async (suffix) => { await stream.writer.u53(SubscribeNamespaceEntryDone.id); await new SubscribeNamespaceEntryDone({ suffix }).encode(stream.writer, version); @@ -1340,9 +1366,11 @@ export class Publisher { // The open PUBLISH_NAMESPACE request per advertised path. const requests: Requests = new Map(); const target: Target = { - advertise: (path, snap) => this.#advertise(path, requests, clusterFor(this.#advert, snap.route)), + advertise: (path, snap) => this.#advertise(path, requests, snap.route), update: (path, _snap, reprice) => this.#update(path, requests, reprice), withdraw: (path) => this.#withdraw(path, requests), + held: (path) => requests.get(path)?.auth, + carry: (auth) => this.#carry(auth), }; // The origin outlives the session and its signal never ends, so this loop needs the @@ -1453,14 +1481,14 @@ export class Publisher { const held = new Map(ns.active); for (const [key, snap] of ns.active) { - if (change(this.#advert, snap, updated.get(key)) !== "restart") continue; + if (this.#change(key, snap, updated.get(key), target) !== "restart") continue; await target.withdraw(key); held.delete(key); } for (const [key, snap] of updated) { const prev = held.get(key); - const diff = change(this.#advert, prev, snap); + const diff = this.#change(key, prev, snap, target); if (diff === "same") continue; let answer: Answer; @@ -1491,10 +1519,41 @@ export class Publisher { // failure clearing, or the peer starting to answer raises no signal of its own, so // the only way back is to ask again on a timer. return [...updated].some( - ([key, snap]) => change(this.#advert, held.get(key), snap) !== "same" && this.#pending(key, ns.refused), + ([key, snap]) => + this.#change(key, held.get(key), snap, target) !== "same" && this.#pending(key, ns.refused), ); } + /** + * What it takes to move the peer from `held` to `next` at `key`: {@link change}, plus + * the token the request carries. + * + * moq-transport renames a request's token with REQUEST_UPDATE, but cannot name one on a + * request that had none, nor drop one, and before draft-17 cannot update a namespace at + * all: those withdraw the request and make it again. + */ + #change( + key: Path.Valid, + held: Advertised | undefined, + next: Advertised | undefined, + target: Target, + ): "same" | "restart" | Reprice { + const diff = change(this.#advert, held, next); + if (diff === "restart" || held === undefined || next === undefined) return diff; + const from = target.held(key); + const to = target.carry(next.route.auth); + if (from === to) return diff; + const version = this.#session.version; + const legacy = version === Version.DRAFT_14 || version === Version.DRAFT_15 || version === Version.DRAFT_16; + if (from === undefined || to === undefined || legacy) return "restart"; + return { ...(diff === "same" ? {} : diff), token: to }; + } + + /** `auth` when a request of this session can carry it. */ + #carry(auth: Token | undefined): Token | undefined { + return this.#auth?.carried(auth) === undefined ? undefined : auth; + } + /** * Whether a namespace may be offered to the peer right now. * @@ -1539,11 +1598,9 @@ export class Publisher { * is logged and skipped: a peer that wants none of this rejects each one and stays * connected. */ - async #advertise( - path: Path.Valid, - requests: Requests, - cluster: Cluster.Advert | undefined = this.#advert, - ): Promise { + async #advertise(path: Path.Valid, requests: Requests, route: Route): Promise { + const cluster = clusterFor(this.#advert, route); + const auth = this.#carry(route.auth); const requestId = await this.#session.nextRequestId(); if (requestId === undefined) return "dropped"; @@ -1563,7 +1620,8 @@ export class Publisher { const answer = await withTimeout( (async (): Promise => { await stream.writer.u53(PublishNamespace.id); - const msg = new PublishNamespace({ requestId, trackNamespace: path, cluster }); + const token = this.#auth?.carried(auth); + const msg = new PublishNamespace({ requestId, trackNamespace: path, cluster, token }); await msg.encode(stream.writer, this.#session.version); // Read response (RequestOk and PublishNamespaceOk share 0x07) @@ -1595,7 +1653,7 @@ export class Publisher { `advertisement timed out after ${ADVERTISE_TIMEOUT_MS}ms waiting for the peer's answer`, ); - if (answer === "held") requests.set(path, { path, requestId, stream }); + if (answer === "held") requests.set(path, { path, requestId, stream, auth }); return answer; } catch (err: unknown) { // The peer never answered the advertisement: a control timeout, not late content. @@ -1620,14 +1678,15 @@ export class Publisher { * A different original publisher is an update like any other: the receiver drains what * it already serves from the old one and never splices the two. */ - async #update(path: Path.Valid, requests: Requests, { from, to }: Reprice): Promise { + async #update(path: Path.Valid, requests: Requests, { from, to, token: auth }: Reprice): Promise { const request = requests.get(path); if (!request) return "dropped"; const update: Cluster.Update = { - hops: sameHops(from.hops, to.hops) ? undefined : to.hops, - cost: from.cost === to.cost ? undefined : to.cost, + hops: from && to && !sameHops(from.hops, to.hops) ? to.hops : undefined, + cost: from && to && from.cost !== to.cost ? to.cost : undefined, }; + const token = auth && this.#auth?.carried(auth); const version = this.#session.version; let answer: Answer; try { @@ -1638,7 +1697,7 @@ export class Publisher { (async (): Promise => { const { writer, reader } = request.stream; await writer.u53(PublishNamespaceUpdate.id); - await new PublishNamespaceUpdate({ requestId, update }).encode(writer, version); + await new PublishNamespaceUpdate({ requestId, update, token }).encode(writer, version); const typeId = await reader.u53(); if (typeId === RequestError.id) { @@ -1664,6 +1723,7 @@ export class Publisher { return "dropped"; } + if (answer === "held" && auth) request.auth = auth; // Before draft-17 the peer already closed out a refused request, and its STOP_SENDING // can reject the wait on our FIN. That is the withdrawal finishing, not a reason to end // the loop. diff --git a/js/net/src/ietf/token.test.ts b/js/net/src/ietf/token.test.ts index f2365e70c14..8b05ebb909f 100644 --- a/js/net/src/ietf/token.test.ts +++ b/js/net/src/ietf/token.test.ts @@ -2,8 +2,16 @@ import { expect, test } from "bun:test"; import { ProtocolViolation, SessionCode } from "../error.ts"; import { Reader, Writer } from "../stream.ts"; import * as Varint from "../varint.ts"; -import { SetupOption, SetupOptions } from "./parameters.ts"; -import { TOKEN_OUT_OF_BAND, type Token, tokenFromRequest, tokenFromSetup, tokenIntoSetup } from "./token.ts"; +import { Parameters, SetupOption, SetupOptions } from "./parameters.ts"; +import { + AUTHORIZATION_TOKEN, + TOKEN_OUT_OF_BAND, + type Token, + tokenFromRequest, + tokenFromSetup, + tokenIntoRequest, + tokenIntoSetup, +} from "./token.ts"; import { type IetfVersion, Version } from "./version.ts"; const VERSIONS: IetfVersion[] = [ @@ -90,6 +98,17 @@ test("USE_VALUE round trips on every draft", async () => { } }); +test("a request carries an out-of-band token by value on every draft", () => { + const value = new Uint8Array([0x6a, 0x77, 0x74]); + for (const version of VERSIONS) { + const params = new Parameters(); + tokenIntoRequest(params, value, version); + const raw = params.bytes.get(AUTHORIZATION_TOKEN); + expect(raw).toBeDefined(); + expect(tokenFromRequest(raw ?? new Uint8Array(), version)).toEqual({ kind: TOKEN_OUT_OF_BAND, value }); + } +}); + /** The same bytes `rs/moq-net/src/ietf/token.rs` asserts, so the two agree on the wire. */ test("the encoding matches the cross-language vector", () => { const token: Token = { kind: 300n, value: new Uint8Array([0x00, 0xff]) }; diff --git a/js/net/src/ietf/token.ts b/js/net/src/ietf/token.ts index 2139e056045..2539b75d4f7 100644 --- a/js/net/src/ietf/token.ts +++ b/js/net/src/ietf/token.ts @@ -1,6 +1,6 @@ import { ProtocolViolation, SessionCode } from "../error.ts"; import * as Varint from "../varint.ts"; -import type { SetupOptions } from "./parameters.ts"; +import type { Parameters, SetupOptions } from "./parameters.ts"; import { type IetfVersion, Version } from "./version.ts"; /** @@ -187,6 +187,20 @@ function decode(raw: Uint8Array, version: IetfVersion, setup: boolean): Token { * @internal */ export function tokenIntoSetup(params: SetupOptions, token: Token, version: IetfVersion) { + params.setBytes(AUTHORIZATION_TOKEN, encode(token, version)); +} + +/** + * Carry `value`, a token in a format agreed out of band, on a request, by value. + * + * @internal + */ +export function tokenIntoRequest(params: Parameters, value: Uint8Array, version: IetfVersion) { + params.bytes.set(AUTHORIZATION_TOKEN, encode({ kind: TOKEN_OUT_OF_BAND, value }, version)); +} + +/** A Token structure by value: USE_VALUE, its type, then the token. */ +function encode(token: Token, version: IetfVersion): Uint8Array { const varint = (v: bigint) => (leadingOnes(version) ? Varint.encodeLeadingOnes(v) : Varint.encode(v)); const aliasType = varint(USE_VALUE); const kind = varint(token.kind); @@ -195,5 +209,5 @@ export function tokenIntoSetup(params: SetupOptions, token: Token, version: Ietf out.set(aliasType, 0); out.set(kind, aliasType.length); out.set(token.value, aliasType.length + kind.length); - params.setBytes(AUTHORIZATION_TOKEN, out); + return out; } diff --git a/js/net/src/lite/publisher.ts b/js/net/src/lite/publisher.ts index b7b2152c59b..63ace4050bc 100644 --- a/js/net/src/lite/publisher.ts +++ b/js/net/src/lite/publisher.ts @@ -479,7 +479,12 @@ export class Publisher { // What the peer decodes for a route: pre-lite-06 wires carry no cost, so a re-price // there must not restart. - const onWire = (route: Route): Route => (hasRouteCost(this.version) ? route : { ...route, cost: Cost.zero }); + // moq-lite carries no per-request auth, so a swap alone sends nothing. + const onWire = (route: Route): Route => ({ + ...route, + cost: hasRouteCost(this.version) ? route.cost : Cost.zero, + auth: undefined, + }); const announce = async (suffix: Path.Valid, route: Route) => { console.debug(`announce: broadcast=${suffix} active=true epoch=${route.epoch}`); diff --git a/js/net/src/origin.ts b/js/net/src/origin.ts index 1061cfcd81c..6b1682033e2 100644 --- a/js/net/src/origin.ts +++ b/js/net/src/origin.ts @@ -1075,7 +1075,7 @@ export interface Table { /** Advertise a prefix and serve requests under it; see {@link Producer.dynamic}. */ dynamic( prefix: Path.Valid, - route?: Route | { epoch?: Route["epoch"]; hops?: Route["hops"]; cost?: Route["cost"] }, + route?: Route | { epoch?: Route["epoch"]; hops?: Route["hops"]; cost?: Route["cost"]; auth?: Route["auth"] }, ): Dynamic; } @@ -1268,7 +1268,14 @@ export class Producer implements Table { */ dynamic( prefix: Path.Valid, - route: Route | { epoch?: Route["epoch"]; hops?: Route["hops"]; cost?: Route["cost"] } = Route.default, + route: + | Route + | { + epoch?: Route["epoch"]; + hops?: Route["hops"]; + cost?: Route["cost"]; + auth?: Route["auth"]; + } = Route.default, ): Dynamic { return this.#insertRoute(prefix, Route.normalize(route), true); } @@ -1281,7 +1288,14 @@ export class Producer implements Table { */ #receive( prefix: Path.Valid, - route: Route | { epoch?: Route["epoch"]; hops?: Route["hops"]; cost?: Route["cost"] } = Route.default, + route: + | Route + | { + epoch?: Route["epoch"]; + hops?: Route["hops"]; + cost?: Route["cost"]; + auth?: Route["auth"]; + } = Route.default, ): Dynamic { return this.#insertRoute(prefix, Route.normalize(route), false); } @@ -2189,7 +2203,9 @@ export class Dynamic { * Route selection still applies: another route still at the old epoch outranks one * without. To re-price, start from the current route, `update({ ...dynamic.route, cost })`. */ - update(route: Route | { epoch?: Route["epoch"]; hops?: Route["hops"]; cost?: Route["cost"] }): void { + update( + route: Route | { epoch?: Route["epoch"]; hops?: Route["hops"]; cost?: Route["cost"]; auth?: Route["auth"] }, + ): void { if (this.#closed) throw new Error("dynamic is closed"); const next = Route.normalize(route); const previous = this.#entry.route.peek().epoch; diff --git a/js/net/src/wire.ts b/js/net/src/wire.ts index 2afbfe51cd6..aba2b230d5c 100644 --- a/js/net/src/wire.ts +++ b/js/net/src/wire.ts @@ -49,7 +49,7 @@ export interface OriginProducer { accepts(prefix: Path.Valid): boolean; receive( prefix: Path.Valid, - route?: Route | { epoch?: Route["epoch"]; hops?: Route["hops"]; cost?: Route["cost"] }, + route?: Route | { epoch?: Route["epoch"]; hops?: Route["hops"]; cost?: Route["cost"]; auth?: Route["auth"] }, ): origin.Dynamic; attach(discovery: boolean): Dispose; expect(): Dispose; diff --git a/quest/m1/auth/request-update.md b/quest/m1/auth/request-update.md index 5b452dc39a9..7c0211919c3 100644 --- a/quest/m1/auth/request-update.md +++ b/quest/m1/auth/request-update.md @@ -66,9 +66,9 @@ because it has a consumer now. The decisions are recorded in the section, and in `doc/concept/standard.md`, which describes only incoming request tokens; the moq-transport path can land before the lite one. -Public API: a new optional auth on routes and subscriptions in Rust and JS. -Wire: none beyond existing REQUEST_UPDATE, SUBSCRIBE_UPDATE and ANNOUNCE_UPDATE, -with lite's auth field from Requests name their auth. +Public API: a new optional auth on routes in Rust and JS. +Wire: none beyond the existing AUTHORIZATION TOKEN on PUBLISH_NAMESPACE and its +REQUEST_UPDATE; lite's ANNOUNCE_UPDATE waits on Requests name their auth. ## Related diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index e8a29ca84ce..b7a5f6996f3 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -764,7 +764,7 @@ impl Handle { } } -/// One credential this side presented, returned by [`Handle::add`]. +/// One credential this side presented, returned by [`Handle`]'s `add`. /// /// Cheap to clone, and every clone names the same credential. It stays presented /// until the last clone drops, which closes its stream and removes its grant from diff --git a/rs/moq-net/src/model/origin.rs b/rs/moq-net/src/model/origin.rs index 2f84eb6429c..daf5341abf1 100644 --- a/rs/moq-net/src/model/origin.rs +++ b/rs/moq-net/src/model/origin.rs @@ -461,7 +461,7 @@ pub struct Route { pub cost: Cost, /// The credential the request advertising this route carries, from - /// [`auth::Handle::add`](crate::auth::Handle::add), or `None` for the session's scope. + /// [`auth::Handle`](crate::auth::Handle)'s `add`, or `None` for the session's scope. /// /// Only a session the auth was presented on carries it, as the token on its /// moq-transport PUBLISH_NAMESPACE: any other session, an answer to a solicited From e903aa2122facd564da10a96c582319be4cd65ac Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 16 Oct 2026 12:37:48 -0700 Subject: [PATCH 07/14] fix(net): an accepted renewal outlives the grant it replaces An acceptor that accepted a renewal's token and dropped the grant it replaces in one step ended the request: the guard polls the old grant first, so it saw the drop before the serve loop applied the renewal. The request grant now tracks the renewal being verified, and a dropped grant whose renewal the acceptor already granted hands over to it. The renewal bench drops each round's grants as the next round lands, without the two-round hold that worked around it. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/benches/auth.rs | 13 +++++----- rs/moq-net/src/auth.rs | 51 ++++++++++++++++++++++++-------------- rs/moq-net/tests/auth.rs | 39 +++++++++++++++++++++++++++++ 3 files changed, 79 insertions(+), 24 deletions(-) diff --git a/rs/moq-net/benches/auth.rs b/rs/moq-net/benches/auth.rs index 2bf1abbf029..f721ffabb84 100644 --- a/rs/moq-net/benches/auth.rs +++ b/rs/moq-net/benches/auth.rs @@ -109,7 +109,9 @@ fn admission(c: &mut Criterion) { /// A client publishing `origin` to its own server, whose session and token requests follow. async fn publisher(origin: &origin::Producer) -> (Session, Session, auth::Requests) { let version: Version = "moq-transport-22".parse().unwrap(); - let (client, server) = create_mock_session_pair(Some(version.alpn())); + let mut mock = moq_net::transport::memory::Config::default(); + mock.protocol = Some(version.alpn().to_string()); + let (client, server) = create_mock_session_pair(mock); // Tokens ride requests alone, and the client announces with PUBLISH_NAMESPACE. let mut client_extensions = moq_net::setup::Extensions::default(); client_extensions.auth = false; @@ -189,13 +191,12 @@ fn renewal(c: &mut Criterion) { issued }) }; - // A round's grants outlive the next round, whose updates go out only once - // the server applied this one's: dropping a grant sooner can end its - // request before the renewal that replaces it lands. - let mut held = [swap(&auths[0]), Vec::new()]; + // Each round's grants replace the last round's, which drop once their + // renewals are accepted. + let mut _held = swap(&auths[0]); let mut next = 1; b.iter(|| { - held[next] = swap(&auths[next]); + _held = swap(&auths[next]); next ^= 1; }); }, diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index 69d5776374b..03dad941dad 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -1311,13 +1311,13 @@ impl RequestVerdict { /// After the first grant, poll for the acceptor's next action on this token: a /// replacement grant (an update, such as a lowered expiry), a refusal (revoke), or the - /// enforced grant being dropped. `Pending` while the grant still stands. - fn poll_reply(&self, waiter: &kio::Waiter) -> Poll { + /// enforced grant being dropped (`None`). `Pending` while the grant still stands. + fn poll_reply(&self, waiter: &kio::Waiter) -> Poll> { let Some(issue) = &self.issue else { - return Poll::Ready(Reply::Refuse { + return Poll::Ready(Some(Reply::Refuse { code: SessionError::Unauthorized, reason: "no verifier".to_string(), - }); + })); }; let mut guard = ready_or!( issue.poll(waiter, |issue| match issue.outbox.is_empty() && !issue.done { @@ -1325,15 +1325,7 @@ impl RequestVerdict { false => Poll::Ready(()), }) ); - Poll::Ready(match guard.outbox.pop_front() { - Some(reply) => reply, - // Done with nothing more to read: the acceptor dropped the enforced grant, ending - // the request. - None => Reply::Refuse { - code: SessionError::Unauthorized, - reason: "grant dropped".to_string(), - }, - }) + Poll::Ready(guard.outbox.pop_front()) } } @@ -1374,6 +1366,8 @@ struct Held { kind: RequestKind, /// The request's slot, shared with a renewal's verification. hold: Option, + /// The token of the renewal being verified, until the serve loop applies its verdict. + renewing: Option>, clock: crate::time::Clock, } @@ -1403,6 +1397,7 @@ impl RequestGrant { path, kind, hold, + renewing: None, clock: clock.clone(), })))) } @@ -1424,8 +1419,10 @@ impl RequestGrant { /// Start verifying a renewal's token, holding the request's slot while it runs. pub(crate) fn verify_renewal(&self, handle: &Handle, token: crate::setup::Token) -> RequestVerdict { - let held = self.held(); - handle.verify_request(token, held.hold.clone()) + let mut held = self.held(); + let verdict = handle.verify_request(token, held.hold.clone()); + held.renewing = verdict.issue.clone(); + verdict } /// Apply a renewal's verdict, or return the refusal that ends the request. @@ -1439,8 +1436,9 @@ impl RequestGrant { verdict: RequestVerdict, res: std::result::Result, ) -> std::result::Result<(), Refusal> { - let grant = res?; let mut held = self.held(); + held.renewing = None; + let grant = res?; if !live(&held.clock, held.kind, held.path.as_str(), &grant) { return Err(Refusal::new(Error::Unauthorized, "renewal not granted")); } @@ -1462,7 +1460,17 @@ impl RequestGrant { return Poll::Ready(Refusal::new(Error::Unauthorized, "request token expired")); } match held.verdict.poll_reply(waiter) { - Poll::Ready(Reply::Grant(grant)) => { + // The acceptor dropped the grant after accepting its renewal, which the serve loop + // has yet to apply: the renewal takes over rather than the request ending between + // the two. + Poll::Ready(None) if held.renewing.as_ref().is_some_and(granted) => return Poll::Pending, + Poll::Ready(None) => { + return Poll::Ready(Refusal::new( + Error::Session(SessionError::Unauthorized), + "grant dropped", + )); + } + Poll::Ready(Some(Reply::Grant(grant))) => { if !held.kind.covers(&grant, held.path.as_str()) { return Poll::Ready(Refusal::new(Error::Unauthorized, "request token no longer granted")); } @@ -1472,7 +1480,7 @@ impl RequestGrant { // be waiting. continue; } - Poll::Ready(Reply::Refuse { code, reason }) => { + Poll::Ready(Some(Reply::Refuse { code, reason })) => { return Poll::Ready(Refusal::new(Error::Session(code), &reason)); } Poll::Pending => return Poll::Pending, @@ -1524,6 +1532,13 @@ fn live(clock: &crate::time::Clock, kind: RequestKind, path: &str, grant: &Grant kind.covers(grant, path) && grant.expires.is_none_or(|expires| expires > clock.now()) } +/// Whether the acceptor granted the token behind `issue` and still holds the grant. +#[cfg_attr(not(feature = "async"), allow(dead_code))] +fn granted(issue: &kio::Shared) -> bool { + let issue = issue.read(); + !issue.done && issue.outbox.iter().any(|reply| matches!(reply, Reply::Grant(_))) +} + /// A grant issued to one of the peer's tokens, which the session enforces. Dropping it /// ends the grant: an AUTH token's leaves the scope, and a request's token ends the request. pub struct Issued { diff --git a/rs/moq-net/tests/auth.rs b/rs/moq-net/tests/auth.rs index 8161bba38c4..c1bcde1720a 100644 --- a/rs/moq-net/tests/auth.rs +++ b/rs/moq-net/tests/auth.rs @@ -192,6 +192,45 @@ async fn moqt_22_a_cancelled_renewal_keeps_the_session() { .expect("timed out"); } +/// An acceptor that accepts a renewal and drops the grant it replaces in one step keeps the +/// announcement: the old grant ending is not the request ending once its replacement is in. +/// The replacement then holds it, so an update that stops covering it ends it. +#[moq_net_sim::test] +async fn moqt_22_dropping_the_old_grant_with_its_renewal_keeps_the_announce() { + within(async { + let (mut pair, client_origin, server_origin) = connect_route_auth(MOQT_22).await; + let mut requests = pair.requests.take().unwrap(); + let first = pair.client.auth().add("first").await.expect("kept for requests"); + let broadcast = client_origin.create_broadcast("room/x").unwrap(); + broadcast.announce(origin::Route::default().with_auth(first)).unwrap(); + let first = requests + .next() + .await + .expect("the announce's token") + .accept(Grant::all()); + wait_announced(&server_origin.consume(), "room/x", true).await; + + let second = pair.client.auth().add("second").await.expect("kept for requests"); + broadcast.announce(origin::Route::default().with_auth(second)).unwrap(); + let renewal = requests.next().await.expect("the renewal's token"); + assert_eq!(renewal.token().as_ref(), b"second"); + let second = renewal.accept(Grant::all()); + drop(first); + + moq_net_sim::sleep(Duration::from_secs(1)).await; + assert!( + futures::FutureExt::now_or_never(second.closed()).is_none(), + "the request ended with the grant its renewal replaced" + ); + + second.update(Grant::default()); + second.closed().await; + assert_eq!(pair.client_transport.close_reason(), None); + }) + .await + .expect("timed out"); +} + /// A pair where the client declines MoQ Auth, so every token the server sees rode a /// request, and the server declines MoQ Solicit, so the client announces with /// PUBLISH_NAMESPACE rather than answering a SUBSCRIBE_NAMESPACE. From 1cd72728f1a50c1637171a43f6c57a51c5460e3f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 16 Oct 2026 12:37:57 -0700 Subject: [PATCH 08/14] feat(js)!: mirror Rust's auth names JS `Auth` (a session's tokens and grants) becomes `Handle`, and `Token` (one presented token) becomes `Auth`, matching `moq_net::auth::Handle` and `moq_net::auth::Auth`. The docs now say a route's token belongs to the session that added it, so a reconnect adds it again and re-announces, and the new "Auth outlives its session" quest carries the connection-level handle. Co-Authored-By: Claude Opus 5.5 --- doc/concept/moq-lite.md | 5 +-- doc/concept/standard.md | 2 +- doc/lib/js/net.md | 2 +- js/net/src/auth.ts | 21 +++++++------ js/net/src/auth_session.ts | 12 +++---- js/net/src/connection/established.ts | 2 +- js/net/src/hop.ts | 6 ++-- js/net/src/ietf/connection.ts | 2 +- js/net/src/ietf/publisher.test.ts | 10 +++--- js/net/src/ietf/publisher.ts | 14 ++++----- js/net/src/lite/connection.ts | 2 +- quest/m1/auth/README.md | 1 + quest/m1/auth/issuer.md | 47 ++++++++++++++++++++++++++++ quest/m1/auth/request-update.md | 11 +++++++ rs/moq-net/src/model/origin.rs | 3 +- rs/moq-tokio/src/connection.rs | 1 + 16 files changed, 102 insertions(+), 39 deletions(-) create mode 100644 quest/m1/auth/issuer.md diff --git a/doc/concept/moq-lite.md b/doc/concept/moq-lite.md index 60d0e24febe..5758168f910 100644 --- a/doc/concept/moq-lite.md +++ b/doc/concept/moq-lite.md @@ -182,8 +182,9 @@ exchange on draft-17+ through the [MoQ Auth extension](/draft/moq-auth), limited to namespace prefixes. Older versions have no grant; the URL token keeps working everywhere. On moq-transport an added token is kept even without the extension, so an announcement can name it: it rides that `PUBLISH_NAMESPACE` as its -authorization token. moq-lite has no per-request token yet, so there an -announcement rides the session's scope. +authorization token. The token belongs to the session that added it, so after a +reconnect the application adds it again and re-announces. moq-lite has no +per-request token yet, so there an announcement rides the session's scope. ## Subscriptions diff --git a/doc/concept/standard.md b/doc/concept/standard.md index a7c8707cd31..3a72c00c7b5 100644 --- a/doc/concept/standard.md +++ b/doc/concept/standard.md @@ -29,7 +29,7 @@ subset drops. What a peer actually observes against this implementation: - **History is one group.** In Rust, Object ID gaps must be marked by Prior Object ID Gap properties for a group to be fetchable. A `FETCH` returns one group from the cache, or on drafts 14 through 19 the saved prefix of the group a new subscription just joined. A range of groups is refused, as is a draft 20+ `LOCATION_FILTER` that ends at Largest Object rather than an absolute End Location. On draft 20 and later, `FETCH_OK`'s End Location is the requested end, capped at Largest Object: a range holding no objects gets an empty fetch stream, and only a start past Largest Object is refused, with `INVALID_RANGE`. A relay's copy with no upstream subscription can't know Largest Object short of the track's end, so it echoes the requested end instead. JavaScript publishing refuses every `FETCH`. Datagrams are never fetchable. A Rust reader that only fetches learns the track from `TRACK_STATUS` instead of subscribing, so a finished track stays fetchable; draft 17 still subscribes, since its `TRACK_STATUS` answer cannot say whether the track is timed, and so does a reader whose publisher refuses `TRACK_STATUS` as `NOT_SUPPORTED`. - **Timing.** A track whose `SUBSCRIBE_OK` declares no `TIMESCALE` is untimed, as is every track on drafts 14 through 16. A Rust reader that never subscribed takes the units from `TRACK_STATUS` on draft 18 and later. - **Strict SETUP and parameters.** Repeated unknown `SETUP` options, GREASE included, are accepted if well-formed; a repeated known option is rejected. On draft 17 and later, a `GROUP_ORDER` other than Ascending (1) or Descending (2) closes the session. Message parameters follow the negotiated draft's lists: drafts 14 and 15 ignore an unknown or misplaced parameter, draft 16 ignores one defined only for another message but closes the session on an unknown one, and draft 17 and later close it on both. -- **One `SETUP` credential**, forwarded to the [auth server](/bin/relay/auth#the-contract) unverified. A second `SETUP` token closes the session. We advertise no token cache, so an alias reference in `SETUP` closes it with `PROTOCOL_VIOLATION`, one on a request with `UNKNOWN_AUTH_TOKEN_ALIAS`, and a registration on a request with `AUTH_TOKEN_CACHE_OVERFLOW`. Where the Rust side authorizes the peer or verifies tokens, a token on a `SUBSCRIBE` or `PUBLISH_NAMESPACE` is verified like an in-band session token and authorizes that one request alone, even where that authorization already covers it; it never widens the session. It is refused with `NOT_SUPPORTED` when nothing verifies it, when it is empty, or when its Token Type is anything but 0 (out of band). A side that does neither, JavaScript included, ignores the token. Rust refuses a request carrying more than one distinct token with `NOT_SUPPORTED`, since it verifies one; JavaScript, which verifies none, ignores them all. A token-authorized `SUBSCRIBE` still stays within the grant the peer gave us. A token-authorized `PUBLISH_NAMESPACE` needs a grant covering everything beneath the namespace, and the tracks we subscribe under it rely on that announce instead of our limit, ending with it. An update renews the token, except on a drafts 14 and 15 `PUBLISH_NAMESPACE`, which has no update, and a refused renewal ends only that request. Rust sends the token a route names (`Route::auth`) on its unsolicited `PUBLISH_NAMESPACE`, and swapping it renews the request with `REQUEST_UPDATE` from draft 17, or ends and re-sends it before; an answer to a solicited `SUBSCRIBE_NAMESPACE` carries no token. A token on any other request is decoded and grants nothing, and a request repeating one is refused with `MALFORMED_AUTH_TOKEN`. `SUBSCRIBE_TRACKS` reads its namespace before refusal; its token is not decoded. On draft 17 and later, the negotiated [MoQ Auth extension](/concept/moq-lite#authorization) accepts additional session tokens in band. +- **One `SETUP` credential**, forwarded to the [auth server](/bin/relay/auth#the-contract) unverified. A second `SETUP` token closes the session. We advertise no token cache, so an alias reference in `SETUP` closes it with `PROTOCOL_VIOLATION`, one on a request with `UNKNOWN_AUTH_TOKEN_ALIAS`, and a registration on a request with `AUTH_TOKEN_CACHE_OVERFLOW`. Where the Rust side authorizes the peer or verifies tokens, a token on a `SUBSCRIBE` or `PUBLISH_NAMESPACE` is verified like an in-band session token and authorizes that one request alone, even where that authorization already covers it; it never widens the session. It is refused with `NOT_SUPPORTED` when nothing verifies it, when it is empty, or when its Token Type is anything but 0 (out of band). A side that does neither, JavaScript included, ignores the token. Rust refuses a request carrying more than one distinct token with `NOT_SUPPORTED`, since it verifies one; JavaScript, which verifies none, ignores them all. A token-authorized `SUBSCRIBE` still stays within the grant the peer gave us. A token-authorized `PUBLISH_NAMESPACE` needs a grant covering everything beneath the namespace, and the tracks we subscribe under it rely on that announce instead of our limit, ending with it. An update renews the token, except on a drafts 14 and 15 `PUBLISH_NAMESPACE`, which has no update, and a refused renewal ends only that request. Rust sends the token a route names (`Route::auth`) on its unsolicited `PUBLISH_NAMESPACE`, and swapping it renews the request with `REQUEST_UPDATE` from draft 17, or ends and re-sends it before; an answer to a solicited `SUBSCRIBE_NAMESPACE` carries no token. The token belongs to the session that added it, so a reconnect means adding it again and re-announcing. A token on any other request is decoded and grants nothing, and a request repeating one is refused with `MALFORMED_AUTH_TOKEN`. `SUBSCRIBE_TRACKS` reads its namespace before refusal; its token is not decoded. On draft 17 and later, the negotiated [MoQ Auth extension](/concept/moq-lite#authorization) accepts additional session tokens in band. - **Refused, not fatal.** A legal request this stack does not serve is rejected on its own and the session stays up: `FORWARD=0`, `SUBSCRIBE_TRACKS`, and the fetch forms above get `NOT_SUPPORTED`. Range Filters get `INVALID_FILTER` on draft 19 and later because the advertised filter limit is zero. On draft 21 and later, namespaces whose first tuple field is exactly `.` or `.session` get `DOES_NOT_EXIST` before application lookup; other dot prefixes still reach the application. Subscription updates accept priority and delivery timeouts; Rust also renews request tokens. Other supported-but-unimplemented preferences end that subscription; a `TRACK_PROPERTY_FILTER` there closes the session with `PROTOCOL_VIOLATION`, since it belongs only to `SUBSCRIBE_TRACKS`. On draft 18 and later a namespace update may change its prefix; suffixes after the acknowledgement are relative to that new prefix. - **Objects and datagrams.** Rust relays preserve Object IDs, gaps, object properties, FIRST\_OBJECT, and END\_OF\_GROUP status objects between moq-transport peers for subgroup 0. Datagrams retain their IDs, properties, priority, and status. Plain Rust readers and moq-lite peers see consecutive frames and normal datagram payloads; JavaScript supports only normal datagrams at Object ID 0. A malformed datagram closes the session; a relayed one the downstream draft cannot carry is dropped. A relayed Timestamp leaves in the units the downstream response declares, and is dropped where it declares none (drafts 14 through 16), as for a plain frame; an immutable one that would change or vanish is refused. Only property 0x10 is read as a Timestamp. Property 0x06 stays opaque and keeps its value regardless of timestamp units; it is dropped across the draft-19 boundary, or refused when immutable, because draft 19 assigns it to SUBGROUP\_DELIVERY\_TIMEOUT. From draft 15, a status object carrying properties is refused, and one relayed from draft 14 leaves without them. Draft 14 allows them, but an END\_OF\_TRACK carrying properties ends the subscription as unsupported, since a track end has nowhere to keep them. A `FETCH` carries the END\_OF\_GROUP object on drafts 14 and 15; later drafts have no FETCH status, so their response ends at the last frame. A group cannot resume across route changes by frame ordinal once Object IDs skip ahead of the ordinals on either route; recovery refuses it instead of splicing different objects. - **Priority.** Higher is served first. The IETF default of 128 is this stack's 127, and a track that never sets one is 127. diff --git a/doc/lib/js/net.md b/doc/lib/js/net.md index 3aa25ae614a..1029334ac00 100644 --- a/doc/lib/js/net.md +++ b/doc/lib/js/net.md @@ -45,7 +45,7 @@ broadcast.announce(); - **Timing is per track, with no default.** A track that declares a `timescale` carries a `timestamp` on every frame; one without is [untimed](/concept/moq-lite#subscriptions) and its frames carry none. Omitting it means untimed, as in Rust. Nothing stamps a frame for you: the publisher passes the timestamp, and the `Timed` type (`{ value, at }`) carries one into the `@moq/json` and `@moq/flate` producers. - **Subscriber staleness is `maxDelay`.** It counts from a group's successor, by media time and by wall clock, whichever reaches it first. Passing the old `maxAge` key throws a `TypeError` naming `maxDelay`. Publisher retention is the separate `Track.Info.maxAge`. - **Hidden paths** stay out of discovery unless the announce request opts in. See [hidden broadcasts](/concept/moq-lite#hidden-broadcasts). -- **Authorization is in band.** On moq-lite 07 (offered only when `moq-lite-07-wip` is listed in `webtransport.protocols`; the WebSocket fallback cannot offer it) and on moq-transport draft-17+ with the [MoQ Auth extension](/draft/moq-auth), `auth.grant` watches what the relay lets this side publish and subscribe to, and `auth.add(token)` presents another token without reconnecting. Publishing outside the grant closes the connection with `SessionCode.Unauthorized`, naming the path. On moq-lite, a subscription the grant stops covering resets with `StreamCode.Unauthorized` and the session stays up. On moq-transport, `auth.add(token)` keeps the token even without the extension, and a broadcast announced with it (`broadcast.announce({ auth: token })`) carries it on that announcement's `PUBLISH_NAMESPACE`; announcing again with another renews the request. +- **Authorization is in band.** On moq-lite 07 (offered only when `moq-lite-07-wip` is listed in `webtransport.protocols`; the WebSocket fallback cannot offer it) and on moq-transport draft-17+ with the [MoQ Auth extension](/draft/moq-auth), `auth.grant` watches what the relay lets this side publish and subscribe to, and `auth.add(token)` presents another token without reconnecting. Publishing outside the grant closes the connection with `SessionCode.Unauthorized`, naming the path. On moq-lite, a subscription the grant stops covering resets with `StreamCode.Unauthorized` and the session stays up. On moq-transport, `auth.add(token)` keeps the token even without the extension, and a broadcast announced with the `Auth.Auth` it returns (`broadcast.announce({ auth })`) carries it on that announcement's `PUBLISH_NAMESPACE`; announcing again with another renews the request. An `Auth.Auth` belongs to the session that added it, so after a reconnect, add the token again and re-announce. - **A graceful close waits.** `await connection.close()` withdraws announcements and drains for up to one second. Application delivery requires explicit `moq-lite-07-wip`; older Lite versions wait only for transport ACKs. IETF media streams are not drained. `abort()` ends immediately. Examples: diff --git a/js/net/src/auth.ts b/js/net/src/auth.ts index f94944eedfd..be52adc511c 100644 --- a/js/net/src/auth.ts +++ b/js/net/src/auth.ts @@ -5,10 +5,11 @@ * sides negotiate MoQ Auth, presents the credential its connection already carried (the * URL, or nothing) right after setup, and learns the {@link Grant} it earned. Older * versions, and peers that do not negotiate it, carry no AUTH exchange: the grant stays - * undefined. There {@link Auth.add} rejects with {@link Unsupported} on moq-lite, and on + * undefined. There {@link Handle.add} rejects with {@link Unsupported} on moq-lite, and on * moq-transport keeps the token, with no grant, for the announcements whose route names it. - * {@link Auth.grant} is the union of every token this side presented, and {@link Auth.add} - * presents another without reconnecting. Mirrors the Rust `moq_net::auth`. + * {@link Handle.grant} is the union of every token this side presented, and {@link Handle.add} + * presents another without reconnecting, returning its {@link Auth}. Mirrors the Rust + * `moq_net::auth`. * * @module */ @@ -47,8 +48,8 @@ export class Unsupported extends Error { } } -/** A token this side presented. {@link Token.close} withdraws it. */ -export interface Token { +/** A token this side presented, which a route can name. {@link Auth.close} withdraws it. */ +export interface Auth { /** This token's own grant: undefined until the peer answers, and again once it ends. */ readonly grant: Getter; /** Settles when the token ends: the peer's revocation, or its withdrawal. */ @@ -95,7 +96,7 @@ export interface Requests { } /** A session's tokens and grants. See the module docs. */ -export interface Auth { +export interface Handle { /** The union of every grant this side holds: undefined until the peer first answers. */ readonly grant: Getter; @@ -105,7 +106,7 @@ export interface Auth { * peer's refusal as an `Error.Session`. On moq-transport without AUTH it resolves at * once with no grant: only a request whose route names it carries it. */ - add(token: string | Uint8Array): Promise; + add(token: string | Uint8Array): Promise; /** * Answer every token the peer presents, instead of the default: granting what this side @@ -120,11 +121,11 @@ export interface Auth { requests(): Requests; } -/** The {@link Auth} of a session without AUTH: no grant, and no tokens. */ -export class None implements Auth { +/** The {@link Handle} of a session without AUTH: no grant, and no tokens. */ +export class None implements Handle { readonly grant: Getter = new Signal(undefined); - add(_token: string | Uint8Array): Promise { + add(_token: string | Uint8Array): Promise { return Promise.reject(new Unsupported()); } diff --git a/js/net/src/auth_session.ts b/js/net/src/auth_session.ts index 5b98d3e36bb..17793d462af 100644 --- a/js/net/src/auth_session.ts +++ b/js/net/src/auth_session.ts @@ -1,13 +1,13 @@ import { wall } from "@moq/clock"; import { type Dispose, type Getter, Once, Signal } from "@moq/signals"; import { - type Auth as AuthApi, + type Auth, type Grant, grantsEqual, + type Handle, type Issued, type Request, type Requests, - type Token, Unsupported, } from "./auth.ts"; import { closeReason, error, ProtocolViolation, SessionCode, SessionError, StreamCode, StreamError } from "./error.ts"; @@ -64,7 +64,7 @@ function cancel(): StreamError { } /** One token this side presented. */ -class Presented implements Token { +class Presented implements Auth { readonly grant = new Signal(undefined); readonly closed: Promise; readonly answered: Promise; @@ -275,7 +275,7 @@ export interface AuthSessionProps { * * @internal */ -export class AuthSession implements AuthApi { +export class AuthSession implements Handle { /** The peer sent a malformed AUTH message; the owning connection closes the session. */ readonly violated = new Once(); #wire?: AuthWire; @@ -316,7 +316,7 @@ export class AuthSession implements AuthApi { return this.#wire !== undefined; } - async add(token: string | Uint8Array): Promise { + async add(token: string | Uint8Array): Promise { if (this.#closed || (!this.#wire && !this.#requests)) throw new Unsupported(); const bytes = typeof token === "string" ? new TextEncoder().encode(token) : token; if (!this.#wire) { @@ -335,7 +335,7 @@ export class AuthSession implements AuthApi { * The bytes a request carries for `token`: undefined for none, for one another session * issued, or for one that ended. */ - carried(token: Token | undefined): Uint8Array | undefined { + carried(token: Auth | undefined): Uint8Array | undefined { if (!this.#requests || !(token instanceof Presented) || !this.#issued.has(token) || token.ended) return undefined; return token.token; diff --git a/js/net/src/connection/established.ts b/js/net/src/connection/established.ts index 872b9129ced..eeddf689af8 100644 --- a/js/net/src/connection/established.ts +++ b/js/net/src/connection/established.ts @@ -34,7 +34,7 @@ export interface Established { * MoQ Auth, each side presents its connection's credential right after setup. * Otherwise the grant stays undefined. */ - readonly auth: Auth.Auth; + readonly auth: Auth.Handle; /** * Subscribe to broadcast announcements matching `scope`, any pattern (`foo/**` diff --git a/js/net/src/hop.ts b/js/net/src/hop.ts index e465c305a92..665ee4dd097 100644 --- a/js/net/src/hop.ts +++ b/js/net/src/hop.ts @@ -8,7 +8,7 @@ * @module */ import * as z from "zod/mini"; -import type { Token } from "./auth.ts"; +import type { Auth } from "./auth.ts"; import type * as Epoch from "./epoch.ts"; /** @@ -104,7 +104,7 @@ export interface Route { * renews the request with REQUEST_UPDATE from draft 17, or ends and makes it again * before. Local: a relay never forwards it. Mirrors `Route::auth` in rs/moq-net. */ - auth?: Token; + auth?: Auth; } /** Constructors and helpers for {@link Route}. */ @@ -113,7 +113,7 @@ export const Route = { default: { hops: [], cost: Cost.zero } as Route, /** Fill route defaults and saturate the static price at the wire ceiling. */ - normalize(route: Route | { epoch?: Epoch.Valid; hops?: readonly Hop[]; cost?: Cost; auth?: Token } = {}): Route { + normalize(route: Route | { epoch?: Epoch.Valid; hops?: readonly Hop[]; cost?: Cost; auth?: Auth } = {}): Route { const cost = route.cost ?? Cost.zero; if (typeof cost !== "bigint" || cost < 0n) throw new RangeError("route cost must be a non-negative bigint"); const max = 2n ** 62n - 1n; diff --git a/js/net/src/ietf/connection.ts b/js/net/src/ietf/connection.ts index f85b681d577..3b81856b755 100644 --- a/js/net/src/ietf/connection.ts +++ b/js/net/src/ietf/connection.ts @@ -62,7 +62,7 @@ export class Connection implements Established { readonly probe: Getter = new Signal({}); /** Our tokens and grants, when the peer negotiated MoQ Auth; see {@link Established.auth}. */ - get auth(): Auth.Auth { + get auth(): Auth.Handle { return this.#auth; } diff --git a/js/net/src/ietf/publisher.test.ts b/js/net/src/ietf/publisher.test.ts index c7e95e47178..b92f20c2301 100644 --- a/js/net/src/ietf/publisher.test.ts +++ b/js/net/src/ietf/publisher.test.ts @@ -1,7 +1,7 @@ import { expect, jest, spyOn, test } from "bun:test"; import { Milli } from "@moq/clock"; import { Signal } from "@moq/signals"; -import type { Grant, Token } from "../auth.ts"; +import type { Auth, Grant } from "../auth.ts"; import { Producer as BroadcastProducer } from "../broadcast.ts"; import * as Epoch from "../epoch.ts"; import { error, ProtocolViolation, StreamCode } from "../error.ts"; @@ -1495,10 +1495,10 @@ function republish(origin: OriginProducer, old: BroadcastProducer) { /** The tokens one session issued, each carried as its own bytes. */ class Tokens { - #issued = new Map(); + #issued = new Map(); - issue(value: string): Token { - const token: Token = { + issue(value: string): Auth { + const token: Auth = { grant: new Signal(undefined), closed: new Promise(() => {}), close() {}, @@ -1507,7 +1507,7 @@ class Tokens { return token; } - carried(token: Token | undefined): Uint8Array | undefined { + carried(token: Auth | undefined): Uint8Array | undefined { return token && this.#issued.get(token); } } diff --git a/js/net/src/ietf/publisher.ts b/js/net/src/ietf/publisher.ts index dff40964321..f7985d64d38 100644 --- a/js/net/src/ietf/publisher.ts +++ b/js/net/src/ietf/publisher.ts @@ -1,6 +1,6 @@ import { Instant, Milli, Nano, now, sleep } from "@moq/clock"; import { type Dispose, type Getter, race, Signal } from "@moq/signals"; -import type { Grant, Token } from "../auth.ts"; +import type { Auth, Grant } from "../auth.ts"; import { enforceGrant } from "../auth_session.ts"; import type * as broadcast from "../broadcast.ts"; import { Withdrawal } from "../connection/withdrawal.ts"; @@ -110,7 +110,7 @@ interface Reprice { from?: Cluster.Advert; to?: Cluster.Advert; /** The token the request should carry instead, when it changed. */ - token?: Token; + token?: Auth; } /** Whether two hop paths list the same hops in the same order. */ @@ -148,7 +148,7 @@ type Refused = "never" | number; type Answer = "held" | "dropped" | Refused; /** The open PUBLISH_NAMESPACE request per advertised path. */ -type Requests = Map; +type Requests = Map; /** What one announce loop knows about the peer, keyed the way that loop names namespaces. */ interface Namespaces { @@ -164,7 +164,7 @@ interface Namespaces { /** The bytes a request carries for a token this session issued. */ interface Carrier { - carried(token: Token | undefined): Uint8Array | undefined; + carried(token: Auth | undefined): Uint8Array | undefined; } /** Where one announce loop sends its advertisements. */ @@ -179,10 +179,10 @@ interface Target { withdraw(key: Path.Valid): Promise; /** The token the peer holds a namespace under, or undefined when its advertisement carries none. */ - held(key: Path.Valid): Token | undefined; + held(key: Path.Valid): Auth | undefined; /** The token an advertisement naming `auth` carries: only a request can carry one. */ - carry(auth: Token | undefined): Token | undefined; + carry(auth: Auth | undefined): Auth | undefined; } /** What {@link Publisher.runGroup} needs to serve one group. */ @@ -1550,7 +1550,7 @@ export class Publisher { } /** `auth` when a request of this session can carry it. */ - #carry(auth: Token | undefined): Token | undefined { + #carry(auth: Auth | undefined): Auth | undefined { return this.#auth?.carried(auth) === undefined ? undefined : auth; } diff --git a/js/net/src/lite/connection.ts b/js/net/src/lite/connection.ts index e6f0cf6c608..839082c905a 100644 --- a/js/net/src/lite/connection.ts +++ b/js/net/src/lite/connection.ts @@ -182,7 +182,7 @@ export class Connection implements Established { } /** Our tokens and grants; see {@link Established.auth}. */ - get auth(): Auth.Auth { + get auth(): Auth.Handle { return this.#auth; } diff --git a/quest/m1/auth/README.md b/quest/m1/auth/README.md index 8a7bf36ff46..a06b8535b21 100644 --- a/quest/m1/auth/README.md +++ b/quest/m1/auth/README.md @@ -131,6 +131,7 @@ because `main` assigned 0x3A to NOT_FETCHABLE first. ## Required - [Live requests follow their auth](/quest/m1/auth/request-update.md) - a route swaps its auth and the session sends the update, keeping the request up +- [Auth outlives its session](/quest/m1/auth/issuer.md) - one `auth::Handle` spans a connection's sessions, so a route's auth survives a reconnect - [Requests name their auth](/quest/m1/auth/request-auth-id.md) - lite-07 AUTH carries an ID and each request names the auth that admits it - [Request gate](/quest/m1/auth/request-gate.md) - every incoming request is refused up front and held to the grant through a gate its dispatcher owns diff --git a/quest/m1/auth/issuer.md b/quest/m1/auth/issuer.md new file mode 100644 index 00000000000..0363996be2b --- /dev/null +++ b/quest/m1/auth/issuer.md @@ -0,0 +1,47 @@ +# [L] Auth outlives its session + +## Goal + +A route on a reconnecting connection names its token once. Today a +`moq_net::auth::Auth` belongs to the session whose `auth::Handle` added it, +and `Route::auth` carries it only on that session, so after a reconnect the +application adds the token again on the new session and re-announces. +`moq_tokio::Connection::auth().add()` keeps a token across sessions, but it +returns moq-tokio's own `connection::auth::Auth`, which a `Route` cannot name. + +## Plan + +- ✅ Decided 2026-10-16 (maintainer, #5695): the long-term shape is a + session-independent issuer in moq-net. An `auth::Handle` outlives sessions + and is passed in with something like `Client::with_auth(handle)` (and the + `Server` equivalent). Each session built on it presents every token the + handle holds, and a `moq_net::auth::Auth` it issued is carried by every + session of that handle. This revisits the "bound to the handle that issued + it" decision of [#5695](https://github.com/moq-dev/moq/pull/5695): the + binding stays, but the handle spans sessions. +- moq-tokio's replay loop moves into moq-net. Today + `rs/moq-tokio/src/connection/auth.rs` spawns a task per added token that + presents it on each new session (`present`, with backoff on a transient + refusal and a final stop on `Unauthorized`). With the handle in moq-net, + `Connection::auth()` returns that `moq_net::auth::Handle`, and + `connection::auth::{Handle, Auth}` go away. +- An `Auth`'s `grant()` and `closed()` follow the session that is live now: the + grant clears while disconnected and returns once the new session's peer + answers, and `closed()` settles only on a refusal or when the handle ends. +- The ietf publisher's check "presented on this session" (the route's `Auth` + was issued by this session's handle) keeps working unchanged once the handle + is shared, so a route survives a reconnect with no re-announce from the app. +- Mirror in `js/net`: the JS `Handle` lives on the reconnecting connection + rather than one session, and `Route.auth` follows it. +- Update `doc/concept/moq-lite.md`, `doc/concept/standard.md` and + `doc/lib/js/net.md`, which today say a reconnect means adding the token + again and re-announcing. + +Public API: breaking in moq-net (`Handle` construction and the `Client`/`Server` +builders), moq-tokio (the `connection::auth` types go away), and js/net. Wire: +none. + +## Related + +- [#5695](https://github.com/moq-dev/moq/pull/5695) - adds `Route::auth`, bound to one session's handle +- [Bindings](/quest/m1/auth/bindings.md) - exposes `Connection::auth()` through moq-ffi, so it should follow this shape diff --git a/quest/m1/auth/request-update.md b/quest/m1/auth/request-update.md index 7c0211919c3..678097caccd 100644 --- a/quest/m1/auth/request-update.md +++ b/quest/m1/auth/request-update.md @@ -41,6 +41,17 @@ because it has a consumer now. The decisions are recorded in the one session, which would make the app re-add and re-announce after every reconnect. moq-tokio's `connection::Auth` and `connection::Token` are renamed so the names match across layers. +- ✅ Revisited 2026-10-16 (maintainer, #5695): the connection-level handle is + a session-independent issuer in moq-net, which needs its own refactor, so + it moves to [Auth outlives its session](/quest/m1/auth/issuer.md). Until + then an `Auth` is bound to one session's handle, and a route on a + reconnecting connection is re-added and re-announced after a reconnect. +- ✅ Decided 2026-10-16 (maintainer, #5695): the JS names mirror Rust's in + this PR: JS `Auth` becomes `Handle` and `Token` becomes `Auth`. +- ✅ Decided 2026-10-16 (maintainer, #5695): an acceptor that accepts a + renewal and drops the grant it replaces in one step keeps the request. The + receiver hands over to the accepted renewal rather than ending between the + two. - ✅ Decided 2026-10-16 (maintainer, #5695): `Route::auth` only. A `Subscription` is aggregated across subscribers before it reaches the wire, so `Subscription::auth` and its aggregation rule are a follow-up. diff --git a/rs/moq-net/src/model/origin.rs b/rs/moq-net/src/model/origin.rs index a8e58f406ff..176b9a9fe71 100644 --- a/rs/moq-net/src/model/origin.rs +++ b/rs/moq-net/src/model/origin.rs @@ -467,7 +467,8 @@ pub struct Route { /// moq-transport PUBLISH_NAMESPACE: any other session, an answer to a solicited /// SUBSCRIBE_NAMESPACE, and moq-lite (which rides the session's scope) advertise /// the route unnamed. Swapping it is an update that renews the request with - /// REQUEST_UPDATE from draft 17, or ends and makes it again before. Local, like + /// REQUEST_UPDATE from draft 17, or ends and makes it again before. After a + /// reconnect, add the token on the new session and name that. Local, like /// [`Self::source`]: a relay never forwards it. pub auth: Option, diff --git a/rs/moq-tokio/src/connection.rs b/rs/moq-tokio/src/connection.rs index 49b6bcf3f9c..92cf2441a87 100644 --- a/rs/moq-tokio/src/connection.rs +++ b/rs/moq-tokio/src/connection.rs @@ -1221,6 +1221,7 @@ impl Connection { } /// Present tokens that survive session replacement and observe the live grant union. + /// A route names a session's own `moq_net::auth::Auth` instead, re-added after a reconnect. pub fn auth(&self) -> auth::Handle { auth::Handle { connection: self.state.clone(), From 07a5a946b0fd699fdc16de0dbb144698e681c1d0 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 16 Oct 2026 18:43:02 -0700 Subject: [PATCH 09/14] fix(net): settle the last renewal and token-lifecycle findings - Rust: a renewal the acceptor granted outlives the old grant's deadline too, not only its drop, when the serve loop applies it late. - JS: closing a token a route names makes the request again without it, instead of leaving the peer holding the withdrawn token. - JS: a request-only token ends with its session. - JS: with MoQ Auth negotiated, a reset AUTH stream fails the token rather than keeping it for requests, matching Rust: only a peer whose SETUP omits the extension leaves tokens to requests. Co-Authored-By: Claude Opus 5.5 --- js/net/src/auth.test.ts | 22 ++++++++++++++- js/net/src/auth.ts | 2 +- js/net/src/auth_session.ts | 31 +++++++++++--------- js/net/src/ietf/publisher.test.ts | 30 +++++++++++++++++++- js/net/src/ietf/publisher.ts | 5 ++++ rs/moq-net/src/auth.rs | 47 ++++++++++++++++++++++++++++--- 6 files changed, 117 insertions(+), 20 deletions(-) diff --git a/js/net/src/auth.test.ts b/js/net/src/auth.test.ts index 05069af5430..75dc28766c0 100644 --- a/js/net/src/auth.test.ts +++ b/js/net/src/auth.test.ts @@ -3,7 +3,7 @@ import { Milli, Nano } from "@moq/clock"; import type { Getter } from "@moq/signals"; import { type Grant, type Issued, Unsupported } from "./auth.ts"; import { accept as acceptSession, connect as connectSession, type Established } from "./connection/index.ts"; -import { SessionCode, SessionError, StreamCode, toStreamCode } from "./error.ts"; +import { SessionCode, SessionError, StreamCode, StreamError, toStreamCode } from "./error.ts"; import { AuthMessage as IetfAuthMessage, IetfAuthWire } from "./ietf/auth.ts"; import * as Ietf from "./ietf/index.ts"; import * as IetfMessage from "./ietf/message.ts"; @@ -379,6 +379,9 @@ test.each([ const token = await client.auth.add("token"); expect(token.grant.peek()).toBeUndefined(); expect(client.auth.grant.peek()).toBeUndefined(); + // The token ends with its session, like any other. + client.abort(); + expect(await token.closed).toBeInstanceOf(Error); } else { await expect(client.auth.add("token")).rejects.toBeInstanceOf(Unsupported); } @@ -386,6 +389,23 @@ test.each([ server.abort(); }); +// With MoQ Auth negotiated, a reset AUTH stream fails the token rather than keeping it for +// requests: only a peer whose SETUP omitted the extension leaves tokens to requests. +test("a reset AUTH stream fails the token on moq-transport", async () => { + const { client, server } = await connect({ publish: new OriginProducer(), protocol: Ietf.ALPN.DRAFT_22 }); + await waitFor(client.auth.grant, (g) => g !== undefined); + const read = spyOn(IetfAuthWire.prototype, "read").mockRejectedValue( + new StreamError(StreamCode.Cancel, { message: "reset" }), + ); + try { + await expect(client.auth.add("token")).rejects.toBeInstanceOf(Unsupported); + } finally { + read.mockRestore(); + client.abort(); + server.abort(); + } +}); + // lite-07 presents the connection's credential on an Auth Stream right away; lite-06 never // opens one. test.each([ diff --git a/js/net/src/auth.ts b/js/net/src/auth.ts index be52adc511c..ca0a4f90507 100644 --- a/js/net/src/auth.ts +++ b/js/net/src/auth.ts @@ -54,7 +54,7 @@ export interface Auth { readonly grant: Getter; /** Settles when the token ends: the peer's revocation, or its withdrawal. */ readonly closed: PromiseLike; - /** Withdraw the token, removing its grant from the union. */ + /** Withdraw the token, removing its grant from the union and remaking any request that carries it without it. */ close(): void; } diff --git a/js/net/src/auth_session.ts b/js/net/src/auth_session.ts index 17793d462af..1dfd2d253db 100644 --- a/js/net/src/auth_session.ts +++ b/js/net/src/auth_session.ts @@ -283,6 +283,7 @@ export class AuthSession implements Handle { #peerGrant: Grant; // Every token this session issued, so a request carries only its own. #issued = new WeakSet(); + #ended = new Signal(0); #union = new Signal(undefined); // The peer replied to some token, so the union is known even when empty. @@ -323,7 +324,7 @@ export class AuthSession implements Handle { // Only a request carries it, and the peer judges it there: no grant to wait for. const presented = new Presented(bytes, false); presented.answer(); - this.#issued.add(presented); + this.#track(presented); return presented; } const presented = this.#present(this.#wire, bytes, false); @@ -341,6 +342,11 @@ export class AuthSession implements Handle { return token.token; } + /** Bumps whenever one of this session's tokens ends, so a request carrying it drops it. */ + get ended(): Getter { + return this.#ended; + } + requests(): Requests { if (this.#acceptor !== "undecided") throw new Error("auth requests already taken or answered by default"); const queue = new RequestQueue(); @@ -405,16 +411,24 @@ export class AuthSession implements Handle { #present(wire: AuthWire, token: Uint8Array, setup: boolean): Presented { const presented = new Presented(token, setup); - this.#tokens.add(presented); - this.#issued.add(presented); + this.#track(presented); if (setup) this.#setupPending.update((n) => n + 1); void this.#run(wire, presented); return presented; } + /** Hold `token` until it ends, so the session's close ends it too. */ + #track(token: Presented) { + this.#tokens.add(token); + this.#issued.add(token); + void token.closed.then(() => { + this.#tokens.delete(token); + this.#ended.update((n) => n + 1); + }); + } + async #run(wire: AuthWire, token: Presented) { let result: Error | null = null; - let predates = false; try { const stream = await wire.present(token.token); token.stream = stream; @@ -463,7 +477,6 @@ export class AuthSession implements Handle { } else if (!token.isAnswered && err instanceof StreamError) { // A peer that predates AUTH resets a stream type it does not know. result = new Unsupported(); - predates = true; } else { result = error(err); if (result instanceof ProtocolViolation && !this.#closed && this.violated.peek() === undefined) { @@ -473,14 +486,6 @@ export class AuthSession implements Handle { } } - // A peer that turned out not to speak AUTH leaves a token for requests to carry. - if (predates && this.#requests) { - token.stream = undefined; - this.#answered(token); - this.#tokens.delete(token); - return; - } - // A token that ends unanswered counts as answered for enforcement: its refusal is // the reply. const unanswered = !token.isAnswered; diff --git a/js/net/src/ietf/publisher.test.ts b/js/net/src/ietf/publisher.test.ts index b92f20c2301..3ee039cfb51 100644 --- a/js/net/src/ietf/publisher.test.ts +++ b/js/net/src/ietf/publisher.test.ts @@ -1495,13 +1495,17 @@ function republish(origin: OriginProducer, old: BroadcastProducer) { /** The tokens one session issued, each carried as its own bytes. */ class Tokens { + readonly ended = new Signal(0); #issued = new Map(); issue(value: string): Auth { const token: Auth = { grant: new Signal(undefined), closed: new Promise(() => {}), - close() {}, + close: () => { + this.#issued.delete(token); + this.ended.update((n) => n + 1); + }, }; this.#issued.set(token, new TextEncoder().encode(value)); return token; @@ -1614,6 +1618,30 @@ test("naming a token on an unnamed request makes it again", async () => { } }); +/** Closing a route's token drops it from the request, which REQUEST_UPDATE cannot do. */ +test("closing a route's token makes its request again without it", async () => { + const tokens = new Tokens(); + const { pub, origin, streams, close } = clustered(false, tokens); + const { sent, restore } = carriedTokens(); + try { + const broadcast = origin.createBroadcast(Path.from("mine")); + const token = tokens.issue("first"); + broadcast.announce({ hops: [VIA], cost: 4n, auth: token }); + void pub.runPublishNamespaces(); + + const old = await take(streams); + await acceptClustered(old); + token.close(); + + expect(await old.reader.done().catch(error)).toMatchObject({ code: StreamCode.Cancel }); + await acceptClustered(await take(streams)); + expect(sent).toEqual(["first", undefined]); + } finally { + restore(); + close(); + } +}); + test("a republish withdraws the PUBLISH_NAMESPACE and advertises again", async () => { const { pub, origin, streams, close } = clustered(); try { diff --git a/js/net/src/ietf/publisher.ts b/js/net/src/ietf/publisher.ts index f7985d64d38..985dc2b100d 100644 --- a/js/net/src/ietf/publisher.ts +++ b/js/net/src/ietf/publisher.ts @@ -165,6 +165,8 @@ interface Namespaces { /** The bytes a request carries for a token this session issued. */ interface Carrier { carried(token: Auth | undefined): Uint8Array | undefined; + /** Bumps whenever one of the session's tokens ends, so a request carrying it drops it. */ + readonly ended: Getter; } /** Where one announce loop sends its advertisements. */ @@ -1404,9 +1406,12 @@ export class Publisher { // A grant change re-diffs the same way, withdrawing what it no longer covers. // The loop top re-reads the table, so an ended origin still stops it there. const grant = this.#grant.changed(() => resolve("changed")); + // A token that ended leaves the request carrying it to be made again without. + const ended = this.#auth?.ended.changed(() => resolve("changed")); dispose = () => { table(); grant(); + ended?.(); }; }); diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index 666589ff8cf..cdc9818d3e4 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -1456,14 +1456,17 @@ impl RequestGrant { pub(crate) fn poll_ended(&self, waiter: &kio::Waiter) -> Poll { let mut held = self.held(); loop { + // The acceptor granted a renewal the serve loop has yet to apply: it takes over rather + // than the request ending between the two, whether the old grant lapsed or dropped. + let handover = held.renewing.as_ref().is_some_and(granted); if held.deadline.poll(waiter).is_ready() { + if handover { + return Poll::Pending; + } return Poll::Ready(Refusal::new(Error::Expired, "request token expired")); } match held.verdict.poll_reply(waiter) { - // The acceptor dropped the grant after accepting its renewal, which the serve loop - // has yet to apply: the renewal takes over rather than the request ending between - // the two. - Poll::Ready(None) if held.renewing.as_ref().is_some_and(granted) => return Poll::Pending, + Poll::Ready(None) if handover => return Poll::Pending, Poll::Ready(None) => { return Poll::Ready(Refusal::new( Error::Session(SessionError::Unauthorized), @@ -2415,6 +2418,42 @@ mod request_token_tests { assert_eq!(handle.grant().peek(), None, "the session grant is untouched"); } + /// A renewal the acceptor granted before the old deadline lapsed keeps the request, even + /// when the serve loop applies it only after the deadline. + #[moq_net_sim::test] + async fn a_granted_renewal_outlives_the_old_deadline() { + let runtime = crate::time::Clock::sim(); + let handle = Handle::new(true); + let mut requests = handle.requests().unwrap(); + let verdict = handle.verify_request(token(b"jwt", 0), None); + let _issued = requests.next().await.unwrap().accept(grant_in(&runtime, 60)); + let answered = verdict.grant().await.unwrap(); + let request_grant = RequestGrant::new( + &runtime, + verdict, + Ok(answered), + subscribe_path(), + RequestKind::Subscribe, + None, + ) + .unwrap(); + + let renewal = request_grant.verify_renewal(&handle, token(b"jwt2", 0)); + let second = grant_in(&runtime, 600); + let second_expires = second.expires; + let _issued2 = requests.next().await.unwrap().accept(second); + moq_net_sim::sleep(Duration::from_secs(61)).await; + + let pending = kio::wait(|waiter| Poll::Ready(request_grant.poll_ended(waiter).is_pending())).await; + assert!(pending, "the lapsed deadline ended a request whose renewal was granted"); + + let renewed = renewal.grant().await; + assert!(request_grant.renew(renewal, renewed).is_ok()); + assert_eq!(request_grant.grant().expires, second_expires); + let pending = kio::wait(|waiter| Poll::Ready(request_grant.poll_ended(waiter).is_pending())).await; + assert!(pending, "the renewal holds the request"); + } + /// A refused renewal verdict is not applied: the request grant it already holds stands /// untouched and ends only when its own deadline lapses. The serve loops end the request on /// a refusal separately (a refused renewal ends only that request); this covers only that From b7750f629bd9ec1aa3d73b899d8c5b0ca246d9a8 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 16 Oct 2026 19:11:35 -0700 Subject: [PATCH 10/14] fix(net): a route auth that ends wakes its request - Rust: a token ending advances the auth epoch even when the union is unchanged, so the namespace loop re-syncs and makes a request carrying it again without it. - JS: add() copies a caller's buffer, so reusing it never changes what a request carries. Co-Authored-By: Claude Opus 5.5 --- js/net/src/auth.test.ts | 6 +++++ js/net/src/auth_session.ts | 3 ++- rs/moq-net/src/auth.rs | 2 ++ rs/moq-net/tests/integration/auth.rs | 40 ++++++++++++++++++++++++++++ 4 files changed, 50 insertions(+), 1 deletion(-) diff --git a/js/net/src/auth.test.ts b/js/net/src/auth.test.ts index 75dc28766c0..93638e0ed76 100644 --- a/js/net/src/auth.test.ts +++ b/js/net/src/auth.test.ts @@ -2,6 +2,7 @@ import { describe, expect, spyOn, test } from "bun:test"; import { Milli, Nano } from "@moq/clock"; import type { Getter } from "@moq/signals"; import { type Grant, type Issued, Unsupported } from "./auth.ts"; +import type { AuthSession } from "./auth_session.ts"; import { accept as acceptSession, connect as connectSession, type Established } from "./connection/index.ts"; import { SessionCode, SessionError, StreamCode, StreamError, toStreamCode } from "./error.ts"; import { AuthMessage as IetfAuthMessage, IetfAuthWire } from "./ietf/auth.ts"; @@ -379,6 +380,11 @@ test.each([ const token = await client.auth.add("token"); expect(token.grant.peek()).toBeUndefined(); expect(client.auth.grant.peek()).toBeUndefined(); + // A request carries the bytes as added, whatever the caller does with its buffer after. + const bytes = new TextEncoder().encode("bytes"); + const copied = await client.auth.add(bytes); + bytes.fill(0); + expect((client.auth as AuthSession).carried(copied)).toEqual(new TextEncoder().encode("bytes")); // The token ends with its session, like any other. client.abort(); expect(await token.closed).toBeInstanceOf(Error); diff --git a/js/net/src/auth_session.ts b/js/net/src/auth_session.ts index 1dfd2d253db..439e85c53ed 100644 --- a/js/net/src/auth_session.ts +++ b/js/net/src/auth_session.ts @@ -319,7 +319,8 @@ export class AuthSession implements Handle { async add(token: string | Uint8Array): Promise { if (this.#closed || (!this.#wire && !this.#requests)) throw new Unsupported(); - const bytes = typeof token === "string" ? new TextEncoder().encode(token) : token; + // A copy, so the caller reusing its buffer never changes what a request carries. + const bytes = typeof token === "string" ? new TextEncoder().encode(token) : token.slice(); if (!this.#wire) { // Only a request carries it, and the peer judges it there: no grant to wait for. const presented = new Presented(bytes, false); diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index cdc9818d3e4..6a9624d7f59 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -583,6 +583,8 @@ impl Handle { if slot.withdrawn { state.tokens.remove(&id); } + // Even with the union unchanged, a request carrying this token must drop it. + state.epoch += 1; state.recompute(); } diff --git a/rs/moq-net/tests/integration/auth.rs b/rs/moq-net/tests/integration/auth.rs index 82e4543fbfb..1ee84591141 100644 --- a/rs/moq-net/tests/integration/auth.rs +++ b/rs/moq-net/tests/integration/auth.rs @@ -231,6 +231,46 @@ async fn moqt_22_dropping_the_old_grant_with_its_renewal_keeps_the_announce() { .expect("timed out"); } +/// A route auth the peer revokes on its AUTH stream is no longer carried, so its request is +/// made again without it, even while another token keeps the session's grant unchanged. +#[moq_net_sim::test] +async fn moqt_22_a_revoked_route_auth_remakes_its_announce() { + within(async { + let client_origin = produce_origin(2); + let server_origin = produce_origin(1); + let mut extensions = moq_net::setup::Extensions::default(); + extensions.solicit = false; + let mut pair = connect(Options { + version: Some(MOQT_22), + client_publish: Some(client_origin.clone()), + server_subscribe: Some(server_origin.clone()), + server_requests: true, + server_extensions: Some(extensions), + ..Default::default() + }) + .await; + let mut tokens = serve(pair.requests.take().unwrap(), |_| Some(Grant::all())); + let (token, _setup) = tokens.next().await.expect("the setup token"); + assert!(token.is_empty()); + + let first = pair.client.auth().add("first").await.expect("granted"); + let (token, presented) = tokens.next().await.expect("the AUTH stream's token"); + assert_eq!(token, b"first"); + let broadcast = client_origin.create_broadcast("room/x").unwrap(); + broadcast.announce(origin::Route::default().with_auth(first)).unwrap(); + let (token, carried) = tokens.next().await.expect("the announce's token"); + assert_eq!(token, b"first"); + wait_announced(&server_origin.consume(), "room/x", true).await; + + presented.revoke(SessionError::Unauthorized, "revoked"); + carried.closed().await; + wait_announced(&server_origin.consume(), "room/x", true).await; + assert_eq!(pair.client_transport.close_reason(), None); + }) + .await + .expect("timed out"); +} + /// A pair where the client declines MoQ Auth, so every token the server sees rode a /// request, and the server declines MoQ Solicit, so the client announces with /// PUBLISH_NAMESPACE rather than answering a SUBSCRIBE_NAMESPACE. From 802124fa86540e5ed13a83cbfa9aceb560e0b335 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 16 Oct 2026 19:37:23 -0700 Subject: [PATCH 11/14] fix(js): wake the solicited legacy loop when a token ends The SUBSCRIBE_NAMESPACE loop on drafts 14 and 15 advertises with PUBLISH_NAMESPACE requests too, so a closed route token must remake its request there as well. Co-Authored-By: Claude Opus 5.5 --- js/net/src/ietf/publisher.test.ts | 45 +++++++++++++++++++++++++++++++ js/net/src/ietf/publisher.ts | 4 +++ 2 files changed, 49 insertions(+) diff --git a/js/net/src/ietf/publisher.test.ts b/js/net/src/ietf/publisher.test.ts index 3ee039cfb51..245e30a9e89 100644 --- a/js/net/src/ietf/publisher.test.ts +++ b/js/net/src/ietf/publisher.test.ts @@ -911,6 +911,51 @@ test("a solicited legacy advertisement refused once is retried", async () => { origin.close(); }); +/** The solicited legacy path carries a route's token too, so closing it remakes the request. */ +test("closing a solicited legacy advertisement's token makes it again without it", async () => { + // No retry timer can stand in for the wake. + jest.useFakeTimers(); + const pair = createMockTransportPair(ALPN.DRAFT_19); + const inner = new NativeSession(pair.server, Version.DRAFT_15, true); + const session: Session = { + version: inner.version, + acceptBi: () => inner.acceptBi(), + nextRequestId: () => inner.nextRequestId(), + spendRequest: (id) => inner.spendRequest(id), + close: () => inner.close(), + openBi: () => inner.openBi(), + }; + const tokens = new Tokens(); + const { pub, origin } = publisher(pair.server, { requiresSolicitation: true, session, auth: tokens }); + const { sent, restore } = carriedTokens(); + try { + const token = tokens.issue("first"); + origin.createBroadcast(Path.from("mine")).announce({ auth: token }); + + const subscription = await Stream.open(pair.client, { version: Version.DRAFT_15 }); + const accepted = await Stream.accept(pair.server, Version.DRAFT_15); + if (!accepted) throw new Error("the subscription stream was never accepted"); + void pub.runSubscribeNamespace(new SubscribeNamespace({ requestId: 0n, namespace: Path.empty() }), accepted); + + const old = await nextStream(pair.client); + if (!old) throw new Error("never advertised"); + expect(await readPublishNamespace(old)).toBe(Path.from("mine")); + await acceptPublishNamespace(old); + await flush(); + token.close(); + + const again = await nextStream(pair.client); + if (!again) throw new Error("never advertised again"); + expect(await readPublishNamespace(again)).toBe(Path.from("mine")); + expect(sent).toEqual(["first", undefined]); + subscription.close(); + } finally { + restore(); + origin.close(); + jest.useRealTimers(); + } +}); + /** * A SUBSCRIBE_NAMESPACE below an advertised route still hears that route: it serves the * requested prefix, so it lands as the empty suffix, then paths beneath the prefix follow diff --git a/js/net/src/ietf/publisher.ts b/js/net/src/ietf/publisher.ts index 985dc2b100d..80e7bbcf0b2 100644 --- a/js/net/src/ietf/publisher.ts +++ b/js/net/src/ietf/publisher.ts @@ -1260,9 +1260,13 @@ export class Publisher { // A grant change re-diffs the same way, withdrawing what it no longer covers. // The loop top re-reads the table, so an ended origin still stops it there. const grant = this.#grant.changed(() => resolve("changed")); + // A token that ended leaves the request carrying it to be made again without. + // A token that ended leaves the request carrying it to be made again without. + const ended = this.#auth?.ended.changed(() => resolve("changed")); dispose = () => { table(); grant(); + ended?.(); }; }); From df28f6b859e8b09f9b0f441bb34cd637c2108137 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 16 Oct 2026 21:31:51 -0700 Subject: [PATCH 12/14] test(js): build the solicited token test on NativeSession directly Main added Session.client; the test needs no wrapper. Co-Authored-By: Claude Opus 5.5 --- js/net/src/ietf/publisher.test.ts | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/js/net/src/ietf/publisher.test.ts b/js/net/src/ietf/publisher.test.ts index 574af2dd683..24739e00475 100644 --- a/js/net/src/ietf/publisher.test.ts +++ b/js/net/src/ietf/publisher.test.ts @@ -919,15 +919,7 @@ test("closing a solicited legacy advertisement's token makes it again without it // No retry timer can stand in for the wake. jest.useFakeTimers(); const pair = createMockTransportPair(ALPN.DRAFT_19); - const inner = new NativeSession(pair.server, Version.DRAFT_15, true); - const session: Session = { - version: inner.version, - acceptBi: () => inner.acceptBi(), - nextRequestId: () => inner.nextRequestId(), - spendRequest: (id) => inner.spendRequest(id), - close: () => inner.close(), - openBi: () => inner.openBi(), - }; + const session = new NativeSession(pair.server, Version.DRAFT_15, true); const tokens = new Tokens(); const { pub, origin } = publisher(pair.server, { requiresSolicitation: true, session, auth: tokens }); const { sent, restore } = carriedTokens(); From f3c8495764e4dac10b3bd167ff727529679eb9a1 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 17 Oct 2026 00:12:04 -0700 Subject: [PATCH 13/14] doc(net): drafts 14 and 15 carry a route's token on solicited answers Their answer to a solicited SUBSCRIBE_NAMESPACE is a PUBLISH_NAMESPACE, which carries the token; only the inline NAMESPACE from draft 16 cannot. Co-Authored-By: Claude Opus 5.5 --- doc/concept/standard.md | 2 +- js/net/src/hop.ts | 9 +++++---- rs/moq-net/src/model/origin.rs | 7 ++++--- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/doc/concept/standard.md b/doc/concept/standard.md index 35228463ae2..a4f21412a9f 100644 --- a/doc/concept/standard.md +++ b/doc/concept/standard.md @@ -29,7 +29,7 @@ subset drops. What a peer actually observes against this implementation: - **History is one group.** In Rust, Object ID gaps must be marked by Prior Object ID Gap properties for a group to be fetchable. A `FETCH` returns one group from the cache, or on drafts 14 through 19 the saved prefix of the group a new subscription just joined. A range of groups is refused, as is a draft 20+ `LOCATION_FILTER` that ends at Largest Object rather than an absolute End Location. On draft 20 and later, `FETCH_OK`'s End Location is the requested end, capped at Largest Object: a range holding no objects gets an empty fetch stream, and only a start past Largest Object is refused, with `INVALID_RANGE`. A relay's copy with no upstream subscription can't know Largest Object short of the track's end, so it echoes the requested end instead. JavaScript publishing refuses every `FETCH`. Datagrams are never fetchable. A Rust reader that only fetches learns the track from `TRACK_STATUS` instead of subscribing, so a finished track stays fetchable; draft 17 still subscribes, since its `TRACK_STATUS` answer cannot say whether the track is timed, and so does a reader whose publisher refuses `TRACK_STATUS` as `NOT_SUPPORTED`. - **Timing.** A track whose `SUBSCRIBE_OK` declares no `TIMESCALE` is untimed, as is every track on drafts 14 through 16. A Rust reader that never subscribed takes the units from `TRACK_STATUS` on draft 18 and later. - **Strict SETUP and parameters.** Repeated unknown `SETUP` options, GREASE included, are accepted if well-formed; a repeated known option is rejected. On draft 17 and later, a `GROUP_ORDER` other than Ascending (1) or Descending (2) closes the session. Message parameters follow the negotiated draft's lists: drafts 14 and 15 ignore an unknown or misplaced parameter, draft 16 ignores one defined only for another message but closes the session on an unknown one, and draft 17 and later close it on both. Other malformed control input closes the session with the draft's code too, such as a server `SETUP` carrying `PATH` or `AUTHORITY`, a request cut short by FIN, or a second `GOAWAY` on one stream; a first `GOAWAY` on a request stream is legal. -- **One `SETUP` credential**, forwarded to the [auth server](/bin/relay/auth#the-contract) unverified. A second `SETUP` token closes the session. We advertise no token cache, so an alias reference in `SETUP` closes it with `PROTOCOL_VIOLATION`, one on a request with `UNKNOWN_AUTH_TOKEN_ALIAS`, and a registration on a request with `AUTH_TOKEN_CACHE_OVERFLOW`. Where the Rust side authorizes the peer or verifies tokens, a token on a `SUBSCRIBE` or `PUBLISH_NAMESPACE` is verified like an in-band session token and authorizes that one request alone, even where that authorization already covers it; it never widens the session. It is refused with `NOT_SUPPORTED` when nothing verifies it, when it is empty, or when its Token Type is anything but 0 (out of band). A side that does neither, JavaScript included, ignores the token. Rust refuses a request carrying more than one distinct token with `NOT_SUPPORTED`, since it verifies one; JavaScript, which verifies none, ignores them all. A token-authorized `SUBSCRIBE` still stays within the grant the peer gave us. A token-authorized `PUBLISH_NAMESPACE` needs a grant covering everything beneath the namespace, and the tracks we subscribe under it rely on that announce instead of our limit, ending with it. An update renews the token, except on a drafts 14 and 15 `PUBLISH_NAMESPACE`, which has no update, and a refused renewal ends only that request. Rust sends the token a route names (`Route::auth`) on its unsolicited `PUBLISH_NAMESPACE`, and swapping it renews the request with `REQUEST_UPDATE` from draft 17, or ends and re-sends it before; an answer to a solicited `SUBSCRIBE_NAMESPACE` carries no token. The token belongs to the session that added it, so a reconnect means adding it again and re-announcing. A token on any other request is decoded and grants nothing, and a request repeating one is refused with `MALFORMED_AUTH_TOKEN`. `SUBSCRIBE_TRACKS` reads its namespace before refusal; its token is not decoded. On draft 17 and later, the negotiated [MoQ Auth extension](/concept/moq-lite#authorization) accepts additional session tokens in band. +- **One `SETUP` credential**, forwarded to the [auth server](/bin/relay/auth#the-contract) unverified. A second `SETUP` token closes the session. We advertise no token cache, so an alias reference in `SETUP` closes it with `PROTOCOL_VIOLATION`, one on a request with `UNKNOWN_AUTH_TOKEN_ALIAS`, and a registration on a request with `AUTH_TOKEN_CACHE_OVERFLOW`. Where the Rust side authorizes the peer or verifies tokens, a token on a `SUBSCRIBE` or `PUBLISH_NAMESPACE` is verified like an in-band session token and authorizes that one request alone, even where that authorization already covers it; it never widens the session. It is refused with `NOT_SUPPORTED` when nothing verifies it, when it is empty, or when its Token Type is anything but 0 (out of band). A side that does neither, JavaScript included, ignores the token. Rust refuses a request carrying more than one distinct token with `NOT_SUPPORTED`, since it verifies one; JavaScript, which verifies none, ignores them all. A token-authorized `SUBSCRIBE` still stays within the grant the peer gave us. A token-authorized `PUBLISH_NAMESPACE` needs a grant covering everything beneath the namespace, and the tracks we subscribe under it rely on that announce instead of our limit, ending with it. An update renews the token, except on a drafts 14 and 15 `PUBLISH_NAMESPACE`, which has no update, and a refused renewal ends only that request. Rust and JavaScript send the token a route names (`Route::auth`) on its `PUBLISH_NAMESPACE`, including the one drafts 14 and 15 answer a solicited `SUBSCRIBE_NAMESPACE` with, and swapping it renews the request with `REQUEST_UPDATE` from draft 17, or ends and re-sends it before; the inline `NAMESPACE` that answers a solicited `SUBSCRIBE_NAMESPACE` from draft 16 carries no token. The token belongs to the session that added it, so a reconnect means adding it again and re-announcing. A token on any other request is decoded and grants nothing, and a request repeating one is refused with `MALFORMED_AUTH_TOKEN`. `SUBSCRIBE_TRACKS` reads its namespace before refusal; its token is not decoded. On draft 17 and later, the negotiated [MoQ Auth extension](/concept/moq-lite#authorization) accepts additional session tokens in band. - **Refused, not fatal.** A legal request this stack does not serve is rejected on its own and the session stays up: `FORWARD=0`, `SUBSCRIBE_TRACKS`, and the fetch forms above get `NOT_SUPPORTED`. Range Filters get `INVALID_FILTER` on draft 19 and later because the advertised filter limit is zero. On draft 21 and later, namespaces whose first tuple field is exactly `.` or `.session` get `DOES_NOT_EXIST` before application lookup; other dot prefixes still reach the application. Subscription updates accept priority and delivery timeouts; Rust also renews request tokens and relays `NEW_GROUP_REQUEST` (see dynamic groups below). Other supported-but-unimplemented preferences end that subscription; a `TRACK_PROPERTY_FILTER` there closes the session with `PROTOCOL_VIOLATION`, since it belongs only to `SUBSCRIBE_TRACKS`. On draft 18 and later a namespace update may change its prefix; suffixes after the acknowledgement are relative to that new prefix. - **Objects and datagrams.** Only subgroup 0 is accepted: a stream for any other subgroup, whether its header gives the ID or takes it from the first Object ID, is stopped and the session stays up. Rust relays preserve Object IDs, gaps, object properties, FIRST\_OBJECT, and END\_OF\_GROUP status objects between moq-transport peers for subgroup 0. Datagrams retain their IDs, properties, priority, and status. Plain Rust readers and moq-lite peers see consecutive frames and normal datagram payloads; JavaScript supports only normal datagrams at Object ID 0. A malformed datagram closes the session; a relayed one the downstream draft cannot carry is dropped. A relayed Timestamp leaves in the units the downstream response declares, and is dropped where it declares none (drafts 14 through 16), as for a plain frame; an immutable one that would change or vanish is refused. Only property 0x10 is read as a Timestamp. Property 0x06 stays opaque and keeps its value regardless of timestamp units; it is dropped across the draft-19 boundary, or refused when immutable, because draft 19 assigns it to SUBGROUP\_DELIVERY\_TIMEOUT. From draft 15, a status object carrying properties is refused, and one relayed from draft 14 leaves without them. Draft 14 allows them, but an END\_OF\_TRACK carrying properties ends the subscription as unsupported, since a track end has nowhere to keep them. A `FETCH` carries the END\_OF\_GROUP object on drafts 14 and 15; later drafts have no FETCH status, so their response ends at the last frame. A group cannot resume across route changes by frame ordinal once Object IDs skip ahead of the ordinals on either route; recovery refuses it instead of splicing different objects. - **Dynamic groups.** A Rust relay advertises `DYNAMIC_GROUPS` only where the original publisher did, and carries `NEW_GROUP_REQUEST` (draft 15 and later) back to it: on the `SUBSCRIBE` that opens the upstream subscription, or in a `REQUEST_UPDATE` once it is established. Requests from every subscriber of a track coalesce, so one that names an existing group or that an outstanding request already covers goes no further. A request for 0 goes upstream as 0, unless another subscriber's request past the newest group held answers it too. The next group the relay receives answers every request it held, so new groups never send one again. On drafts 15 and 16, whose `TRACK_STATUS` answer has no room for it, a relay copy first learned from `TRACK_STATUS` (a fetch-only reader) does not advertise it to later subscribers. Local publishers never advertise `DYNAMIC_GROUPS`; on their tracks a `SUBSCRIBE`'s request is ignored and an update carrying one ends the subscription with `UPDATE_FAILED`. moq-lite hops do not carry requests yet. diff --git a/js/net/src/hop.ts b/js/net/src/hop.ts index 665ee4dd097..db60d91c3e6 100644 --- a/js/net/src/hop.ts +++ b/js/net/src/hop.ts @@ -99,10 +99,11 @@ export interface Route { /** * The token the request advertising this route carries, from `auth.add()`, or undefined * for the session's own credential. Only the session that issued it carries it, as the - * token on its moq-transport PUBLISH_NAMESPACE: any other session, an answer to a - * solicited SUBSCRIBE_NAMESPACE, and moq-lite advertise the route unnamed. Swapping it - * renews the request with REQUEST_UPDATE from draft 17, or ends and makes it again - * before. Local: a relay never forwards it. Mirrors `Route::auth` in rs/moq-net. + * token on its moq-transport PUBLISH_NAMESPACE: any other session, the inline NAMESPACE + * answering a solicited SUBSCRIBE_NAMESPACE from draft 16 on (drafts 14 and 15 answer + * with a PUBLISH_NAMESPACE, which carries it), and moq-lite advertise the route unnamed. + * Swapping it renews the request with REQUEST_UPDATE from draft 17, or ends and makes it + * again before. Local: a relay never forwards it. Mirrors `Route::auth` in rs/moq-net. */ auth?: Auth; } diff --git a/rs/moq-net/src/model/origin.rs b/rs/moq-net/src/model/origin.rs index 83bcdc37c89..bc44f34258a 100644 --- a/rs/moq-net/src/model/origin.rs +++ b/rs/moq-net/src/model/origin.rs @@ -464,9 +464,10 @@ pub struct Route { /// [`auth::Handle`](crate::auth::Handle)'s `add`, or `None` for the session's scope. /// /// Only a session the auth was presented on carries it, as the token on its - /// moq-transport PUBLISH_NAMESPACE: any other session, an answer to a solicited - /// SUBSCRIBE_NAMESPACE, and moq-lite (which rides the session's scope) advertise - /// the route unnamed. Swapping it is an update that renews the request with + /// moq-transport PUBLISH_NAMESPACE: any other session, the inline NAMESPACE answering a + /// solicited SUBSCRIBE_NAMESPACE from draft 16 on (drafts 14 and 15 answer with a + /// PUBLISH_NAMESPACE, which carries it), and moq-lite (which rides the session's scope) + /// advertise the route unnamed. Swapping it is an update that renews the request with /// REQUEST_UPDATE from draft 17, or ends and makes it again before. After a /// reconnect, add the token on the new session and name that. Local, like /// [`Self::source`]: a relay never forwards it. From 8982b9140bdea38c619d455fc4bc47a41084dc5f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 17 Oct 2026 00:25:47 -0700 Subject: [PATCH 14/14] fix(net): a reset renewal ends only its request A peer that resets a request's stream while its renewal is in flight made the read fail, which ended the whole announce loop. A stream error now ends only that request, like a FIN; a malformed answer still ends the loop. Also drop a duplicated comment line and state the issuer quest's goal as its outcome. Co-Authored-By: Claude Opus 5.5 --- js/net/src/ietf/publisher.ts | 1 - quest/m1/auth/issuer.md | 15 ++++++---- rs/moq-net/src/ietf/publisher.rs | 51 +++++++++++++++++++++++++++++++- 3 files changed, 59 insertions(+), 8 deletions(-) diff --git a/js/net/src/ietf/publisher.ts b/js/net/src/ietf/publisher.ts index f4c97f97e9e..7e4075d0328 100644 --- a/js/net/src/ietf/publisher.ts +++ b/js/net/src/ietf/publisher.ts @@ -1267,7 +1267,6 @@ export class Publisher { // The loop top re-reads the table, so an ended origin still stops it there. const grant = this.#grant.changed(() => resolve("changed")); // A token that ended leaves the request carrying it to be made again without. - // A token that ended leaves the request carrying it to be made again without. const ended = this.#auth?.ended.changed(() => resolve("changed")); dispose = () => { table(); diff --git a/quest/m1/auth/issuer.md b/quest/m1/auth/issuer.md index 0363996be2b..1f4a1af0e06 100644 --- a/quest/m1/auth/issuer.md +++ b/quest/m1/auth/issuer.md @@ -2,15 +2,18 @@ ## Goal -A route on a reconnecting connection names its token once. Today a -`moq_net::auth::Auth` belongs to the session whose `auth::Handle` added it, -and `Route::auth` carries it only on that session, so after a reconnect the -application adds the token again on the new session and re-announces. -`moq_tokio::Connection::auth().add()` keeps a token across sessions, but it -returns moq-tokio's own `connection::auth::Auth`, which a `Route` cannot name. +A route on a reconnecting connection names its token once: the same +`moq_net::auth::Auth` keeps riding its announcements on every session the +connection makes, with no re-add or re-announce from the application. ## Plan +- Today a `moq_net::auth::Auth` belongs to the session whose `auth::Handle` + added it, and `Route::auth` carries it only on that session, so after a + reconnect the application adds the token again on the new session and + re-announces. `moq_tokio::Connection::auth().add()` keeps a token across + sessions, but it returns moq-tokio's own `connection::auth::Auth`, which a + `Route` cannot name. - ✅ Decided 2026-10-16 (maintainer, #5695): the long-term shape is a session-independent issuer in moq-net. An `auth::Handle` outlives sessions and is passed in with something like `Client::with_auth(handle)` (and the diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index e13d6277864..a8b4bbd6f08 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -3240,7 +3240,16 @@ where request.pending = None; let absolute = self.origin.absolute(&request.path).to_owned(); - let refused = match answer? { + // A reset stream ends only this request, like a FIN; a malformed answer is the peer's + // violation and still ends the loop. + let answer = match answer { + Err(err) if !super::subscriber::is_protocol_violation(&err) => { + tracing::debug!(broadcast = %absolute, %err, "update stream ended"); + Answer::Ended + } + answer => answer?, + }; + let refused = match answer { Answer::Late => { tracing::debug!(broadcast = %absolute, "no answer to the update"); // Abrupt: a peer that never answers is not owed the FIN handshake. @@ -13592,6 +13601,46 @@ mod tests { ); } + /// A peer that resets a request's stream while its renewal is in flight ends only that + /// request: the loop keeps running and advertises the namespace afresh. + #[moq_net_sim::test] + async fn a_reset_renewal_ends_only_its_request() { + const VERSION: Version = Version::Draft19; + + let origin = unheld_origin(); + let auth = crate::auth::Handle::ietf(false); + let present = |token: &'static [u8]| auth.present(bytes::Bytes::from_static(token), false).unwrap(); + let cam = origin + .announce("cam", crate::origin::Route::default().with_auth(present(b"first"))) + .unwrap(); + settle().await; + + let ok = publish_namespace_ok(VERSION).await; + let session = crate::lite::test_transport::ScriptedSession::per_stream_reset(vec![ok.clone(), ok]); + let log = session.log.clone(); + let publisher = Publisher::new( + crate::time::Clock::sim(), + session, + origin.consume(), + Control::new(None, false), + None, + declared(Some(false)), + VERSION, + ) + .with_auth(auth.clone()); + let mut run = std::pin::pin!(publisher.run_publish_namespaces()); + poll_until(&mut run, &log, b"first", 1).await; + cam.update(crate::origin::Route::default().with_auth(present(b"second"))) + .unwrap(); + poll_until(&mut run, &log, b"second", 2).await; + assert_eq!( + occurrences(&log, b"second"), + 2, + "the renewal and the advertisement made again after the reset" + ); + assert_eq!(log.bi_opens(), 2, "the reset request was replaced"); + } + /// An update the peer never answers drops its request once the timeout passes, and /// the retry advertises the namespace afresh under the current auth. #[moq_net_sim::test]