From 9c28c39fa3872cd90e1afbd264f2db7955ef560a Mon Sep 17 00:00:00 2001 From: Luke Curley <432854+kixelated@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:27:04 -0700 Subject: [PATCH 01/45] quest: claim m1/auth/issuer From 793b60e7b0ff1fb04bfc65f01a91ead9283e05a3 Mon Sep 17 00:00:00 2001 From: Luke Curley <432854+kixelated@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:45:51 -0700 Subject: [PATCH 02/45] quest: fold the one-active-session decision for m1/auth/issuer --- quest/m1/auth/issuer.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/quest/m1/auth/issuer.md b/quest/m1/auth/issuer.md index 1f4a1af0e06..98b69e5bcb7 100644 --- a/quest/m1/auth/issuer.md +++ b/quest/m1/auth/issuer.md @@ -28,6 +28,11 @@ connection makes, with no re-add or re-announce from the application. 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. +- Decided 2026-10-20 (maintainer): a handle backs one active session at a + time, plus the predecessors it is draining, matching the reconnect code's + single active generation. Attaching a handle to a second concurrent session + fails loudly; independent concurrent sessions use separate handles, so an + `Auth` never has to pick between two live grants. - 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. From d0cf12c26fce3f1c83e00d73db376fefcd09f49e Mon Sep 17 00:00:00 2001 From: Luke Curley <432854+kixelated@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:27:29 -0700 Subject: [PATCH 03/45] feat(moq-net): an auth handle that outlives its sessions Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/auth.rs | 1401 ++++++++++++++++++-------- rs/moq-net/src/client.rs | 31 +- rs/moq-net/src/goaway.rs | 5 + rs/moq-net/src/ietf/auth.rs | 33 +- rs/moq-net/src/ietf/publisher.rs | 134 +-- rs/moq-net/src/ietf/session.rs | 60 +- rs/moq-net/src/ietf/subscriber.rs | 50 +- rs/moq-net/src/lite/publisher.rs | 40 +- rs/moq-net/src/lite/session.rs | 43 +- rs/moq-net/src/lite/subscriber.rs | 72 +- rs/moq-net/src/model/origin.rs | 10 +- rs/moq-net/src/server.rs | 22 +- rs/moq-net/src/session.rs | 22 +- rs/moq-net/tests/integration/auth.rs | 203 +++- 14 files changed, 1482 insertions(+), 644 deletions(-) diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index 0bdeccb2320..0448cbba544 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -9,6 +9,11 @@ //! 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. //! +//! A handle passed to `Client::with_auth` outlives its sessions: each session built on it +//! presents every token it holds, so a route naming one of its [`Auth`]s keeps it across a +//! reconnect. Only the peer's verdict ends such a token; a session ending leaves it for the +//! next. It backs one active session at a time, plus the ones draining after a GOAWAY. +//! //! The other direction, answering the peer's tokens, is automatic: the session //! grants what its own origin handles allow. An application that verifies tokens //! itself takes [`requests`](Handle::requests) before running the session's @@ -33,6 +38,7 @@ use std::{ collections::{BTreeMap, VecDeque}, task::Poll, + time::Duration, }; use bytes::Bytes; @@ -122,46 +128,85 @@ fn earliest(a: Option, b: Option) -> Option { } } -/// The shared state behind every auth handle of one session. +/// How long a token refused for a reason that may pass waits before its session presents it +/// again, doubling on each refusal up to [`RETRY_MAX`]. +const RETRY_INITIAL: Duration = Duration::from_secs(1); +const RETRY_MAX: Duration = Duration::from_secs(30); + +/// Whether a refusal is the acceptor failing to decide rather than its verdict on the +/// token, such as an unreachable auth server or a full verification queue. +fn transient(err: &Error) -> bool { + matches!( + err, + Error::Session(SessionError::Internal | SessionError::TooManyRequests | SessionError::Timeout) + ) +} + +/// The shared state behind one [`Handle`]: the tokens it presents, and every session +/// it backs. #[derive(Default)] pub(crate) struct State { + /// Created by a session for itself, so it ends with that session. + bound: bool, + /// Set once the handle ends; every waiter resolves with it. + closed: Option, + #[cfg_attr(not(feature = "async"), allow(dead_code))] + next_id: u64, + /// Every token the application added that it has not withdrawn. + tokens: BTreeMap, + next_link: u64, + /// Every session built on this handle that has not dropped, oldest first. + links: BTreeMap, + /// What the peer's connection credential lets it do, in the peer's terms: `None` (only + /// the origin handles bound it) until [`Handle::authorize`] sets it; the grant it was + /// last called with. + limit: Option, + #[cfg_attr(not(feature = "async"), allow(dead_code))] + next_issue: u64, + acceptor: Acceptor, +} + +/// A token the application added, presented on every session of its handle. +struct Token { + value: Bytes, + /// The first verdict any session's peer gave: `Ok` for a grant, or why it ended. + answered: Option>, + /// Why the token ended for good: a peer's refusal or revocation, or the handle's end. + ended: Option, +} + +/// One session's side of a handle. +struct Attached { /// 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. + /// Set once the session ends. closed: Option, - #[cfg_attr(not(feature = "async"), allow(dead_code))] - next_id: u64, - /// Every token this side presented that has not ended. - tokens: BTreeMap, + /// The session's GOAWAY: once either side sends one, the session is draining. + goaway: Option>, + /// Every token this session presented that has not been withdrawn. + slots: BTreeMap, /// Tokens waiting for the driver to open their stream. opening: VecDeque, /// The union of every open token's grant, `None` until the peer first replies. union: Option, /// The peer replied to some token, so the union is known even when empty. replied: bool, - /// What the peer's connection credential lets it do, in the peer's terms: `None` (only - /// the origin handles bound it) until [`Handle::authorize`] sets it; the grant it was - /// last called with. - limit: Option, /// The grants the application issued to the peer's AUTH tokens, by stream, in the /// peer's terms. A token on a single request never joins them. issued: BTreeMap, - #[cfg_attr(not(feature = "async"), allow(dead_code))] - next_issue: u64, /// The session's scope for the peer: the limit and every issued grant, `None` while the /// limit is. scope: Option, /// Bumped whenever the union or the scope changes, so the session's per-stream /// gates can skip re-matching paths on every wakeup. epoch: u64, - acceptor: Acceptor, } -/// One presented token. -pub(crate) struct Slot { +/// One token as one session presents it. +struct Slot { #[cfg_attr(not(feature = "async"), allow(dead_code))] token: Bytes, /// Presented by the session itself at setup, so enforcement waits on it. @@ -171,16 +216,40 @@ pub(crate) struct Slot { 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. + /// The latest 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. + /// The peer refused the current attempt with an AUTH_ERROR. + refused: bool, + /// The delay before the last retry, which the next one doubles. + retry: Option, + /// Why the token ended on this session, once it has. ended: Option, /// The last [`Auth`] clone dropped: the driver closes the stream. withdrawn: bool, } -/// Who answers the peer's tokens. Decided once, when the driver first runs. +impl Slot { + fn new(token: Bytes, setup: bool, answered: Option>) -> Self { + let ended = match &answered { + Some(Err(err)) => Some(err.clone()), + _ => None, + }; + Self { + token, + setup, + grant: None, + streamed: false, + answered, + refused: false, + retry: None, + ended, + withdrawn: false, + } + } +} + +/// Who answers the peer's tokens. Decided once, when a driver first runs. #[derive(Default)] enum Acceptor { /// Nobody asked yet; the app may still take [`Handle::requests`]. @@ -194,9 +263,80 @@ enum Acceptor { Default, } -impl State { +impl Attached { + fn new(supported: bool, requests: bool, limit: Option<&Grant>) -> Self { + let mut attached = Self { + supported, + requests, + closed: None, + goaway: None, + slots: BTreeMap::new(), + opening: VecDeque::new(), + union: None, + replied: false, + issued: BTreeMap::new(), + scope: None, + epoch: 0, + }; + attached.rescope(limit); + attached + } + + /// Whether the session still takes new work: open, with no GOAWAY sent or received. + fn active(&self) -> bool { + self.closed.is_none() && !self.goaway.as_ref().is_some_and(|goaway| goaway.draining()) + } + + /// Present the handle's token `id` on this session, returning the answer when the + /// session settles it at once: `Ok` when only a request can carry it, so nothing will + /// answer, and [`Error::Unsupported`] when nothing can. + fn offer(&mut self, id: u64, token: &Bytes) -> Option> { + if self.closed.is_some() { + return None; + } + let answered = match (self.supported, self.requests) { + (true, _) => None, + (false, true) => Some(Ok(())), + (false, false) => Some(Err(Error::Unsupported)), + }; + self.slots.insert(id, Slot::new(token.clone(), false, answered.clone())); + if self.supported { + self.opening.push_back(id); + } + answered + } + + /// End token `id` on this session, if it is still live here. + fn end(&mut self, id: u64, err: &Error) { + let Some(slot) = self.slots.get_mut(&id) else { + return; + }; + if slot.ended.is_some() { + return; + } + slot.grant = None; + slot.answered.get_or_insert(Err(err.clone())); + slot.ended = Some(err.clone()); + self.epoch += 1; + self.recompute(); + } + + /// The last [`Auth`] clone for token `id` dropped. + fn withdraw(&mut self, id: u64) { + let Some(slot) = self.slots.get_mut(&id) else { + return; + }; + slot.withdrawn = true; + // Already over, or no stream whose close would end it: nothing left to close. + if slot.ended.is_some() || !slot.streamed { + self.opening.retain(|opening| *opening != id); + self.slots.remove(&id); + self.recompute(); + } + } + fn recompute(&mut self) { - let mut granted = self.tokens.values().filter_map(|slot| slot.grant.as_ref()).peekable(); + let mut granted = self.slots.values().filter_map(|slot| slot.grant.as_ref()).peekable(); if granted.peek().is_none() && !self.replied { return; } @@ -210,9 +350,9 @@ impl State { } } - /// Re-derive the scope from the limit and the issued grants. - fn rescope(&mut self) { - let scope = self.limit.as_ref().map(|limit| { + /// Re-derive the scope from the handle's limit and the issued grants. + fn rescope(&mut self, limit: Option<&Grant>) { + let scope = limit.map(|limit| { let mut scope = limit.clone(); for grant in self.issued.values() { scope.union(grant); @@ -225,11 +365,6 @@ impl State { } } - /// The error handed to anything waiting on a session that has ended. - fn closed(&self) -> Option { - self.closed.clone() - } - /// The union's and the scope's patterns for `direction`, each `None` while /// unrestricted. #[cfg_attr(not(feature = "async"), allow(dead_code))] @@ -266,6 +401,58 @@ impl State { } } +impl State { + /// The session the handle backs now: the newest one still open. + fn live(&self) -> Option<&Attached> { + self.links.values().rev().find(|link| link.closed.is_none()) + } + + /// Record a session's answer to token `id`: a grant settles the application's `add`, and + /// a refusal is the verdict that ends the token on every session. + fn answer(&mut self, id: u64, answer: Result<()>) { + match answer { + Ok(()) => { + if let Some(token) = self.tokens.get_mut(&id) { + token.answered.get_or_insert(Ok(())); + } + } + Err(err) => self.end_token(id, err), + } + } + + /// End token `id` for good, closing its stream on every session. + fn end_token(&mut self, id: u64, err: Error) { + let Some(token) = self.tokens.get_mut(&id) else { + return; + }; + if token.ended.is_some() { + return; + } + token.answered.get_or_insert(Err(err.clone())); + token.ended = Some(err.clone()); + for link in self.links.values_mut() { + link.end(id, &err); + } + } + + /// End the handle, returning the application's queue for the caller to close once the + /// lock is released, since refusing its tokens takes the lock again. + fn abort(&mut self, err: Error) -> Option { + if self.closed.is_some() { + return None; + } + self.closed = Some(err.clone()); + let ids: Vec = self.tokens.keys().copied().collect(); + for id in ids { + self.end_token(id, err.clone()); + } + match &self.acceptor { + Acceptor::App(queue) => Some(queue.clone()), + _ => None, + } + } +} + /// What one direction of a session allows: the grant the peer gave this side (the /// union of its tokens) and the limit this side set on the peer, each `None` /// while unrestricted. @@ -308,130 +495,94 @@ impl Permit { } } -/// The session's auth handle, returned by `Session::auth`. +/// The tokens this side presents and the grant they earn, across every session built on it. /// -/// Cheap to clone; every clone shares the session's tokens. -#[derive(Clone)] +/// A session made without one creates its own, returned by `Session::auth`, which ends +/// with that session. Pass one to `Client::with_auth` instead and it outlives its +/// sessions: each presents every token the handle holds, so an [`Auth`] it issued keeps +/// riding a route's announcements across a reconnect. It backs one active session at a +/// time, plus the ones draining after a GOAWAY. +/// +/// Cheap to clone; every clone shares the same tokens. +#[derive(Clone, Default)] pub struct Handle { state: kio::Shared, } impl Handle { - /// A handle for a session that speaks AUTH (`supported`) or never will. - #[cfg_attr(not(feature = "async"), allow(dead_code))] - pub(crate) fn new(supported: bool) -> Self { - Self { - state: kio::Shared::new(State { - supported, - ..Default::default() - }), - } + /// A handle backing no session yet, for `Client::with_auth`. + pub fn new() -> Self { + Self::default() } - /// 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))] - pub(crate) fn supported(&self) -> bool { - self.state.lock().supported - } - - /// The union of every grant this side holds: `None` until the peer first - /// answers a token (with a grant or a refusal), and forever on a version - /// without AUTH. + /// The union of every grant this side holds on the live session: `None` until its peer + /// first answers a token (with a grant or a refusal), while no session is live, and + /// forever on a version without AUTH. pub fn grant(&self) -> Watch { Watch::new(self.state.clone(), Selector::Union) } - /// Present another token, resolving once the peer answers it. + /// Present another token on every session of this handle, resolving once a peer answers it. /// - /// 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. + /// The grant joins the union while any clone of the returned [`Auth`] lives. With no + /// session live it waits for the next one. A refusal that may pass, such as an + /// unreachable auth server, is presented again after a backoff. 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 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. + /// as [`Error::Session`], or the handle's end. #[cfg(feature = "async")] pub async fn add(&self, token: impl Into) -> Result { - let auth = self.present(token.into(), false)?; + let auth = self.present(token.into())?; kio::wait(|waiter| auth.poll_answered(waiter)).await?; Ok(auth) } - /// Queue a token for the driver to present. + /// Register a token and queue it on every open session. #[cfg_attr(not(feature = "async"), allow(dead_code))] - pub(crate) fn present(&self, token: Bytes, setup: bool) -> Result { - let mut state = self.state.lock(); - if !state.supported && !state.requests { - return Err(Error::Unsupported); - } - if let Some(err) = state.closed() { - return Err(err); + pub(crate) fn present(&self, token: Bytes) -> Result { + let mut guard = self.state.lock(); + let state = &mut *guard; + if let Some(err) = &state.closed { + return Err(err.clone()); } 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, - streamed: false, - answered, + Token { + value: token.clone(), + answered: None, ended: None, - withdrawn: false, }, ); - if state.supported { - state.opening.push_back(id); + let answers: Vec<_> = state + .links + .values_mut() + .filter_map(|link| link.offer(id, &token)) + .collect(); + for answer in answers { + state.answer(id, answer); } Ok(Auth { presented: std::sync::Arc::new(Presented { state: self.state.clone(), id, + link: None, }), }) } - /// 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`. /// - /// Call it before running the session's driver: whoever answers is decided - /// once, on the driver's first poll, so a later call cannot take over tokens + /// Call it before running the first session's driver: whoever answers is decided + /// once, on that driver's first poll, so a later call cannot take over tokens /// already in flight. Without it the session grants the peer what its own /// origin handles allow for the connection's credential, and refuses any other /// token as unsupported, along with the request that carried it. @@ -442,7 +593,7 @@ impl Handle { /// /// # Errors /// - /// [`Error::Duplicate`] if the requests were already taken, or the driver + /// [`Error::Duplicate`] if the requests were already taken, or a driver /// already started answering them itself. pub fn requests(&self) -> Result { let mut state = self.state.lock(); @@ -457,6 +608,51 @@ impl Handle { Ok(Requests { queue }) } + /// Authorize the peer's connection credential for `grant` on every session of this + /// handle, in the session's own paths. Calling it again re-authorizes, narrower or + /// wider, replacing the last grant. The session's origin handles still bound it, so it + /// never grants more than they allow. + /// + /// The session's scope for the peer is this grant and every grant issued to one of its + /// AUTH tokens through [`requests`](Self::requests); a session never authorized is bound + /// only by its origin handles. A request that carries its own token is decided by that + /// token instead, inside the scope or not. + /// + /// A narrower scope ends what falls outside at once, with [`Error::Unauthorized`] + /// where it has a reader: announcements to the peer retract, its new requests are + /// refused, its subscriptions reset, and the broadcasts it published abort. A wider + /// one brings back what the old scope held back: announcements to the peer are made + /// again, and so are the peer's announcements it withheld from the origin, and new + /// requests are accepted. The rest of the session carries on. It works on every + /// version, since the session enforces it without the peer's help. + /// + /// When the session answers the peer's connection credential itself, the peer is + /// told the grant, within what the origin handles allow, `expires` included. An + /// application answering tokens through [`requests`](Self::requests) updates its own + /// [`Issued`] grants. + pub fn authorize(&self, grant: &Grant) { + let mut guard = self.state.lock(); + let state = &mut *guard; + if state.limit.as_ref() == Some(grant) { + return; + } + state.limit = Some(grant.clone()); + for link in state.links.values_mut() { + link.epoch += 1; + link.rescope(state.limit.as_ref()); + } + } + + /// End the handle with `err`: every token ends, its sessions stop presenting them, and + /// the peer's queued tokens are refused. A handle a session created for itself ends + /// with that session. + pub fn abort(&self, err: Error) { + let queue = self.state.lock().abort(err); + if let Some(queue) = queue { + queue.close(); + } + } + /// Ask the acceptor to verify a token that rode on one of the peer's requests. /// /// The token reaches it through [`requests`](Self::requests) like any other. The grant it @@ -487,7 +683,7 @@ impl Handle { /// Decide who answers the peer's tokens: the app if it took the requests, /// otherwise the session itself (`None`). Idempotent. #[cfg_attr(not(feature = "async"), allow(dead_code))] - pub(crate) fn acceptor(&self) -> Option { + fn acceptor(&self) -> Option { let mut state = self.state.lock(); match &state.acceptor { Acceptor::App(queue) => Some(queue.clone()), @@ -498,40 +694,249 @@ impl Handle { } } } +} + +/// One session's side of a [`Handle`]: the tokens its driver presents, the peer's tokens +/// it answers, and the grants it enforces. +/// +/// Cheap to clone; the session leaves its handle when the last clone drops. +#[derive(Clone)] +pub(crate) struct Link { + handle: Handle, + attached: std::sync::Arc, +} + +/// Removes a session from its handle once nothing holds its [`Link`]. +struct Detach { + state: kio::Shared, + id: u64, +} + +impl Drop for Detach { + fn drop(&mut self) { + let queue = { + let mut state = self.state.lock(); + state.links.remove(&self.id); + // Dropped without closing, such as a driver dropped mid-session. + match state.bound { + true => state.abort(Error::Cancel), + false => None, + } + }; + if let Some(queue) = queue { + queue.close(); + } + } +} + +impl Link { + /// A session that speaks AUTH (`supported`) or never will, on a handle of its own. + #[cfg_attr(not(feature = "async"), allow(dead_code))] + pub(crate) fn new(supported: bool) -> Self { + Self::bound(supported, false) + } + + /// A moq-transport session: like [`new`](Self::new), but a request can carry a token + /// itself, so a token needs no AUTH to be presented. + #[cfg_attr(not(feature = "async"), allow(dead_code))] + pub(crate) fn ietf(supported: bool) -> Self { + Self::bound(supported, true) + } + + fn bound(supported: bool, requests: bool) -> Self { + let handle = Handle { + state: kio::Shared::new(State { + bound: true, + ..Default::default() + }), + }; + Self::attach(&handle, supported, requests).expect("a fresh handle backs no session") + } + + /// Build a session on `handle`, the moq-transport flavor when `requests`. + /// + /// # Errors + /// + /// [`Error::Duplicate`] while the handle backs another active session, or the + /// handle's end. + pub(crate) fn attach(handle: &Handle, supported: bool, requests: bool) -> Result { + let mut guard = handle.state.lock(); + let state = &mut *guard; + if let Some(err) = &state.closed { + return Err(err.clone()); + } + if state.links.values().any(Attached::active) { + return Err(Error::Duplicate); + } + let id = state.next_link; + state.next_link += 1; + let mut link = Attached::new(supported, requests, state.limit.as_ref()); + let answers: Vec<_> = state + .tokens + .iter() + .filter(|(_, token)| token.ended.is_none()) + .filter_map(|(token_id, token)| Some((*token_id, link.offer(*token_id, &token.value)?))) + .collect(); + state.links.insert(id, link); + for (token_id, answer) in answers { + state.answer(token_id, answer); + } + Ok(Self { + handle: handle.clone(), + attached: std::sync::Arc::new(Detach { + state: handle.state.clone(), + id, + }), + }) + } + + /// The handle this session is built on. + pub(crate) fn handle(&self) -> &Handle { + &self.handle + } + + fn state(&self) -> &kio::Shared { + &self.handle.state + } + + fn id(&self) -> u64 { + self.attached.id + } + + /// Run `f` on this session's side of the handle. + fn with(&self, f: impl FnOnce(&mut Attached) -> R) -> R { + self.with_state(|state, id| f(attached(&mut state.links, id))) + } + + /// Run `f` on the handle's state, with this session's id. + fn with_state(&self, f: impl FnOnce(&mut State, u64) -> R) -> R { + f(&mut self.state().lock(), self.id()) + } + + /// Read this session's side of the handle, and the handle's state. + fn read(&self, f: impl FnOnce(&Attached, &State) -> R) -> R { + let state = self.state().read(); + let link = state.links.get(&self.id()).expect("a link outlives its session's state"); + f(link, &state) + } + + /// Retire the session to draining once `goaway` is sent or received, freeing the + /// handle for a replacement. + pub(crate) fn drain_with(&self, goaway: std::sync::Arc) { + self.with(|link| link.goaway = Some(goaway)); + } + + /// 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))] + pub(crate) fn supported(&self) -> bool { + self.read(|link, _| link.supported) + } + + /// Present a token: the session's own credential at setup, presented on this session + /// alone, or else one of the handle's, presented on every session. + #[cfg_attr(not(feature = "async"), allow(dead_code))] + pub(crate) fn present(&self, token: Bytes, setup: bool) -> Result { + if !setup { + return self.handle.present(token); + } + let id = self.with_state(|state, link| { + let id = state.next_id; + let link = attached(&mut state.links, link); + if !link.supported && !link.requests { + return Err(Error::Unsupported); + } + if let Some(err) = &link.closed { + return Err(err.clone()); + } + // Without AUTH only a request presents it, so nothing will answer. + let answered = (!link.supported).then_some(Ok(())); + link.slots.insert(id, Slot::new(token, true, answered)); + if link.supported { + link.opening.push_back(id); + } + state.next_id += 1; + Ok(id) + })?; + Ok(Auth { + presented: std::sync::Arc::new(Presented { + state: self.state().clone(), + id, + link: Some(self.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 here, 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; + } + self.read(|link, _| { + if !link.requests { + return None; + } + let slot = link.slots.get(&auth.presented.id)?; + slot.ended.is_none().then(|| slot.token.clone()) + }) + } + + /// Ask the acceptor to verify a token that rode on one of the peer's requests; see + /// [`Handle::verify_request`]. + #[cfg_attr(not(feature = "async"), allow(dead_code))] + pub(crate) fn verify_request(&self, token: crate::setup::Token, hold: Option) -> RequestVerdict { + self.handle.verify_request(token, hold) + } + + /// Decide who answers the peer's tokens: the app if it took the requests, + /// otherwise the session itself (`None`). Idempotent. + #[cfg_attr(not(feature = "async"), allow(dead_code))] + pub(crate) fn acceptor(&self) -> Option { + self.handle.acceptor() + } /// The next token to open a stream for, or `Ready(None)` once the session ends. #[cfg_attr(not(feature = "async"), allow(dead_code))] pub(crate) fn poll_opening(&self, waiter: &kio::Waiter) -> Poll> { - let mut state = ready_or!(self.state.poll(waiter, |state| { - match state.opening.is_empty() && state.closed.is_none() { - true => Poll::Pending, - false => Poll::Ready(()), + let id = self.id(); + loop { + let mut guard = ready_or!(self.state().poll(waiter, |state| { + match state.links.get(&id) { + Some(link) if link.opening.is_empty() && link.closed.is_none() => Poll::Pending, + _ => Poll::Ready(()), + } + })); + let Some(link) = guard.links.get_mut(&id) else { + return Poll::Ready(None); + }; + if link.closed.is_some() { + return Poll::Ready(None); } - })); - if state.closed.is_some() { - return Poll::Ready(None); - } - let id = state.opening.pop_front().expect("checked non-empty"); - 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. - None => { - drop(state); - self.poll_opening(waiter) + let token = link.opening.pop_front().expect("checked non-empty"); + // Withdrawn or ended before its stream opened: nothing to present. + if let Some(slot) = link.slots.get_mut(&token) + && slot.ended.is_none() + { + slot.streamed = true; + slot.refused = false; + return Poll::Ready(Some((token, slot.token.clone()))); } } } - /// Whether the token's handle dropped, so its stream should close. + /// Whether the token's stream should close: its handle dropped, or it ended, here or + /// for good on another session. #[cfg_attr(not(feature = "async"), allow(dead_code))] pub(crate) fn poll_withdrawn(&self, id: u64, waiter: &kio::Waiter) -> Poll<()> { - let _ = ready_or!(self.state.poll(waiter, |state| match state.tokens.get(&id) { - Some(slot) if !slot.withdrawn => Poll::Pending, - _ => Poll::Ready(()), + let link = self.id(); + let _ = ready_or!(self.state().poll(waiter, |state| { + match state.links.get(&link).and_then(|link| link.slots.get(&id)) { + Some(slot) if !slot.withdrawn && slot.ended.is_none() => Poll::Pending, + _ => Poll::Ready(()), + } })); Poll::Ready(()) } @@ -547,53 +952,125 @@ impl Handle { subscribe = %list(&grant.subscribe), "auth granted" ); - let mut state = self.state.lock(); - let Some(slot) = state.tokens.get_mut(&id) else { - return; - }; - slot.grant = Some(grant); - slot.answered.get_or_insert(Ok(())); - state.replied = true; - state.recompute(); + self.with_state(|state, link| { + let link = attached(&mut state.links, link); + let Some(slot) = link.slots.get_mut(&id) else { + return; + }; + if slot.ended.is_some() { + return; + } + slot.grant = Some(grant); + slot.answered = Some(Ok(())); + slot.retry = None; + link.replied = true; + link.recompute(); + state.answer(id, Ok(())); + }); } /// Record an AUTH_ERROR for the token: a reply that grants nothing, so a refused /// setup token leaves an empty union rather than an unknown (unrestricted) one. - /// The driver still ends the token with [`ended`](Self::ended). + /// The driver then calls [`retry`](Self::retry), and [`ended`](Self::ended) unless it + /// presents the token again. #[cfg_attr(not(feature = "async"), allow(dead_code))] pub(crate) fn refused(&self, id: u64) { - let mut state = self.state.lock(); - if state.tokens.contains_key(&id) { - state.replied = true; - } + self.with(|link| { + if let Some(slot) = link.slots.get_mut(&id) { + slot.refused = true; + link.replied = true; + } + }); + } + + /// The delay before presenting a refused token again, when the refusal `err` may pass + /// and the token is still wanted here; `None` when the driver should end it instead. + #[cfg_attr(not(feature = "async"), allow(dead_code))] + pub(crate) fn retry(&self, id: u64, err: &Error) -> Option { + self.with(|link| { + if link.closed.is_some() || !transient(err) { + return None; + } + let slot = link.slots.get_mut(&id)?; + if !slot.refused || slot.withdrawn || slot.ended.is_some() { + return None; + } + slot.refused = false; + slot.streamed = false; + slot.grant = None; + // Answered, so nothing waits on the token through the backoff. + slot.answered = Some(Err(err.clone())); + let delay = slot.retry.map_or(RETRY_INITIAL, |delay| (delay * 2).min(RETRY_MAX)); + slot.retry = Some(delay); + link.epoch += 1; + link.recompute(); + Some(delay) + }) + } + + /// The token to present again once a [`retry`](Self::retry) delay passes, or `None` + /// when it is no longer wanted here. + #[cfg_attr(not(feature = "async"), allow(dead_code))] + pub(crate) fn reopen(&self, id: u64) -> Option { + self.with(|link| { + if link.closed.is_some() { + return None; + } + let slot = link.slots.get_mut(&id)?; + if slot.withdrawn || slot.ended.is_some() { + return None; + } + slot.streamed = true; + Some(slot.token.clone()) + }) } - /// End the token: refused or revoked by the peer, withdrawn by us, or its - /// stream lost. A token that was never answered records `err` as its answer. + /// End the token on this session: refused or revoked by the peer, withdrawn by us, or + /// its stream lost. A token that was never answered records `err` as its answer. The + /// peer's verdict ends one of the handle's tokens on every session; anything else + /// leaves it for the next session to present. #[cfg_attr(not(feature = "async"), allow(dead_code))] pub(crate) fn ended(&self, id: u64, err: Error) { - let mut state = self.state.lock(); - let Some(slot) = state.tokens.get_mut(&id) else { - return; - }; - slot.grant = None; - slot.answered.get_or_insert(Err(err.clone())); - slot.ended = Some(err); - // The handle is gone and the token is over: nothing will read the slot again. - 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(); + self.with_state(|state, link| { + let link = attached(&mut state.links, link); + let Some(slot) = link.slots.get_mut(&id) else { + return; + }; + let verdict = !slot.setup + && link.closed.is_none() + && !slot.withdrawn + && slot.ended.is_none() + && (slot.refused + || matches!(err, Error::Unsupported) + // The peer ended the grant without revoking it. + || (matches!(err, Error::Cancel) && slot.grant.is_some())); + slot.grant = None; + slot.answered.get_or_insert(Err(err.clone())); + slot.ended.get_or_insert(err.clone()); + // The handle is gone and the token is over: nothing will read the slot again. + if slot.withdrawn { + link.slots.remove(&id); + } + // Even with the union unchanged, a request carrying this token must drop it. + link.epoch += 1; + link.recompute(); + if verdict { + state.end_token(id, err); + } + }); } /// Resolve once every token the session presented at setup has its first /// reply, so enforcement never waits on a token the app added later. #[cfg_attr(not(feature = "async"), allow(dead_code))] pub(crate) fn poll_setup_answered(&self, waiter: &kio::Waiter) -> Poll<()> { - let _ = ready_or!(self.state.poll(waiter, |state| { - match state.tokens.values().any(|slot| slot.setup && slot.answered.is_none()) { + let id = self.id(); + let _ = ready_or!(self.state().poll(waiter, |state| { + let pending = state + .links + .get(&id) + .is_some_and(|link| link.slots.values().any(|slot| slot.setup && slot.answered.is_none())); + match pending { true => Poll::Pending, false => Poll::Ready(()), } @@ -604,11 +1081,15 @@ impl Handle { /// Wait only when a token already presented might widen the grant for this path. #[cfg_attr(not(feature = "async"), allow(dead_code))] fn poll_presented(&self, direction: Direction, path: &str, waiter: &kio::Waiter) -> Poll<()> { - let _ = ready_or!(self.state.poll(waiter, |state| { - let (granted, _) = state.parts(direction); + let id = self.id(); + let _ = ready_or!(self.state().poll(waiter, |state| { + let Some(link) = state.links.get(&id) else { + return Poll::Ready(()); + }; + let (granted, _) = link.parts(direction); if granted.is_some_and(|grant| grant.matches(path)) - || !state - .tokens + || !link + .slots .values() .any(|slot| !slot.token.is_empty() && slot.answered.is_none() && !slot.withdrawn) { @@ -620,46 +1101,17 @@ impl Handle { Poll::Ready(()) } - /// Authorize the peer's connection credential for `grant` on this session, in the - /// session's own paths. Calling it again re-authorizes, narrower or wider, replacing the - /// last grant. The session's origin handles still bound it, so it never grants more than - /// they allow. - /// - /// The session's scope for the peer is this grant and every grant issued to one of its - /// AUTH tokens through [`requests`](Self::requests); a session never authorized is bound - /// only by its origin handles. A request that carries its own token is decided by that - /// token instead, inside the scope or not. - /// - /// A narrower scope ends what falls outside at once, with [`Error::Unauthorized`] - /// where it has a reader: announcements to the peer retract, its new requests are - /// refused, its subscriptions reset, and the broadcasts it published abort. A wider - /// one brings back what the old scope held back: announcements to the peer are made - /// again, and so are the peer's announcements it withheld from the origin, and new - /// requests are accepted. The rest of the session carries on. It works on every - /// version, since the session enforces it without the peer's help. - /// - /// When the session answers the peer's connection credential itself, the peer is - /// told the grant, within what the origin handles allow, `expires` included. An - /// application answering tokens through [`requests`](Self::requests) updates its own - /// [`Issued`] grants. - pub fn authorize(&self, grant: &Grant) { - let mut state = self.state.lock(); - if state.limit.as_ref() != Some(grant) { - state.limit = Some(grant.clone()); - state.epoch += 1; - state.rescope(); - } - } - /// Record the grant an application issued to the peer's AUTH token `id`, or forget it with /// `None`, resizing the scope. fn issue(&self, id: u64, grant: Option) { - let mut state = self.state.lock(); - match grant { - Some(grant) => state.issued.insert(id, grant), - None => state.issued.remove(&id), - }; - state.rescope(); + self.with_state(|state, link| { + let link = attached(&mut state.links, link); + match grant { + Some(grant) => link.issued.insert(id, grant), + None => link.issued.remove(&id), + }; + link.rescope(state.limit.as_ref()); + }); } /// The union once it changed since `epoch` last saw it, advancing `epoch`. It @@ -667,8 +1119,9 @@ impl Handle { #[cfg_attr(not(feature = "async"), allow(dead_code))] pub(crate) fn poll_union(&self, epoch: &mut u64, waiter: &kio::Waiter) -> Poll> { let state = ready_or!(self.poll_epoch(*epoch, waiter)); - *epoch = state.epoch; - Poll::Ready(state.union.clone()) + let link = &state.links[&self.id()]; + *epoch = link.epoch; + Poll::Ready(link.union.clone()) } /// What `direction` allows once it may have changed since `epoch` last saw it, @@ -676,21 +1129,23 @@ impl Handle { #[cfg_attr(not(feature = "async"), allow(dead_code))] pub(crate) fn poll_permit(&self, direction: Direction, epoch: &mut u64, waiter: &kio::Waiter) -> Poll { let state = ready_or!(self.poll_epoch(*epoch, waiter)); - *epoch = state.epoch; - Poll::Ready(state.permit(direction)) + let link = &state.links[&self.id()]; + *epoch = link.epoch; + Poll::Ready(link.permit(direction)) } /// The limit once it may have changed since `epoch` last saw it, advancing `epoch`. #[cfg_attr(not(feature = "async"), allow(dead_code))] pub(crate) fn poll_limit(&self, epoch: &mut u64, waiter: &kio::Waiter) -> Poll> { let state = ready_or!(self.poll_epoch(*epoch, waiter)); - *epoch = state.epoch; + *epoch = state.links[&self.id()].epoch; Poll::Ready(state.limit.clone()) } #[cfg_attr(not(feature = "async"), allow(dead_code))] fn poll_epoch(&self, seen: u64, waiter: &kio::Waiter) -> Poll> { - self.state.poll(waiter, |state| match state.epoch != seen { + let id = self.id(); + self.state().poll(waiter, |state| match state.links[&id].epoch != seen { true => Poll::Ready(()), false => Poll::Pending, }) @@ -701,7 +1156,7 @@ impl Handle { /// limit both. #[cfg_attr(not(feature = "async"), allow(dead_code))] pub(crate) fn allows(&self, direction: Direction, path: &str) -> bool { - self.state.read().allows(direction, path) + self.read(|link, _| link.allows(direction, path)) } /// Whether a request token decides the request carrying it, rather than the session's @@ -712,85 +1167,115 @@ impl Handle { /// admits as it would without it. #[cfg_attr(not(feature = "async"), allow(dead_code))] pub(crate) fn token_decides(&self, direction: Direction) -> bool { - let state = self.state.read(); - let (_, scope) = state.parts(direction); - scope.is_some() || matches!(state.acceptor, Acceptor::App(_)) + self.read(|link, state| { + let (_, scope) = link.parts(direction); + scope.is_some() || matches!(state.acceptor, Acceptor::App(_)) + }) } /// Whether the peer's grant alone allows `direction` at `path`, allowing everything /// while it is `None`: what bounds this side whatever authorized the request. #[cfg_attr(not(feature = "async"), allow(dead_code))] pub(crate) fn within_grant(&self, direction: Direction, path: &str) -> bool { - let state = self.state.read(); - let (granted, _) = state.parts(direction); - granted.is_none_or(|granted| granted.matches(path)) + self.read(|link, _| { + let (granted, _) = link.parts(direction); + granted.is_none_or(|granted| granted.matches(path)) + }) } /// Whether the scope alone allows `direction` at `path`: for a route the peer /// offers us, which our own grant does not decide. #[cfg_attr(not(feature = "async"), allow(dead_code))] pub(crate) fn within_limit(&self, direction: Direction, path: &str) -> bool { - let state = self.state.read(); - let (_, scope) = state.parts(direction); - scope.is_none_or(|scope| scope.matches(path)) + self.read(|link, _| { + let (_, scope) = link.parts(direction); + scope.is_none_or(|scope| scope.matches(path)) + }) } /// 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. + /// requests that name it; elsewhere each fails as unsupported, which ends the handle's + /// tokens for good. 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; + self.with_state(|state, link| { + let link = attached(&mut state.links, link); + link.supported = false; + link.opening.clear(); + let requests = link.requests; + let mut answers = Vec::new(); + for (id, slot) in &mut link.slots { + if slot.withdrawn { + continue; + } + let answer = match requests { + true => Ok(()), + false => Err(Error::Unsupported), + }; + slot.answered.get_or_insert(answer.clone()); + if let Err(err) = &answer { + slot.ended.get_or_insert(err.clone()); + } + if !slot.setup { + answers.push((*id, answer)); + } } - slot.answered.get_or_insert(Err(Error::Unsupported)); - slot.ended.get_or_insert(Error::Unsupported); - } - // Nothing will read a withdrawn slot again. - state.tokens.retain(|_, slot| !slot.withdrawn); + // Nothing will read a withdrawn slot again. + link.slots.retain(|_, slot| !slot.withdrawn); + for (id, answer) in answers { + state.answer(id, answer); + } + }); } - /// End the session: fail every pending token, end every watch, and close the - /// requests. + /// Why the session ended, once it has. + #[cfg_attr(not(feature = "async"), allow(dead_code))] + fn closed(&self) -> Option { + self.read(|link, _| link.closed.clone()) + } + + /// End the session: fail every pending token here, leaving the handle's tokens for the + /// next session. A handle the session created for itself ends with it. #[cfg_attr(not(feature = "async"), allow(dead_code))] pub(crate) fn close(&self, err: Error) { - let mut state = self.state.lock(); - if state.closed.is_some() { - return; - } - state.closed = Some(err.clone()); - state.opening.clear(); - for slot in state.tokens.values_mut() { - slot.grant = None; - slot.answered.get_or_insert(Err(err.clone())); - slot.ended.get_or_insert(err.clone()); - } - state.recompute(); - let queue = match &state.acceptor { - Acceptor::App(queue) => Some(queue.clone()), - _ => None, - }; - // Refusing the queued tokens takes this lock again, to drop them from the scope. - drop(state); + let queue = self.with_state(|state, link| { + let link = attached(&mut state.links, link); + if link.closed.is_some() { + return None; + } + link.closed = Some(err.clone()); + link.opening.clear(); + for slot in link.slots.values_mut() { + slot.grant = None; + slot.answered.get_or_insert(Err(err.clone())); + slot.ended.get_or_insert(err.clone()); + } + link.recompute(); + match state.bound { + true => state.abort(err), + false => None, + } + }); + // Refusing the queued tokens takes the lock again, to drop them from the scope. if let Some(queue) = queue { queue.close(); } } } +/// A session's side of the handle, which lives as long as any [`Link`] to it. +fn attached(links: &mut BTreeMap, id: u64) -> &mut Attached { + links.get_mut(&id).expect("a link outlives its session's state") +} + /// 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 -/// the union. A [`Route`](crate::origin::Route) holding one keeps it presented while -/// the route stands. +/// Cheap to clone, and every clone names the same credential. It stays presented on +/// every session of its handle until the last clone drops, which closes its streams and +/// removes its grant from the union. A [`Route`](crate::origin::Route) holding one keeps +/// it presented while the route stands, across the handle's sessions. #[derive(Clone)] pub struct Auth { presented: std::sync::Arc, @@ -804,45 +1289,64 @@ impl Auth { } } -/// The slot every clone of one [`Auth`] shares, withdrawn when the last drops. +/// The token every clone of one [`Auth`] shares, withdrawn when the last drops. struct Presented { state: kio::Shared, id: u64, + /// The one session that presents it: a session's own credential. `None` for a + /// handle's token, presented on every session. + link: Option, +} + +impl Presented { + fn selector(&self) -> Selector { + match self.link { + Some(link) => Selector::Setup { link, id: self.id }, + None => Selector::Token(self.id), + } + } } impl Auth { - /// This credential's own grant: `None` until the peer answers, forever on - /// moq-transport without AUTH, and again once the credential ends. + /// This credential's own grant on the live session: `None` until its peer answers, + /// while no session is live, forever on moq-transport without AUTH, and again once + /// the credential ends. pub fn grant(&self) -> Watch { - Watch::new(self.presented.state.clone(), Selector::Slot(self.presented.id)) + Watch::new(self.presented.state.clone(), self.presented.selector()) } - /// 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. + /// Wait for the credential to end for good: a peer's refusal or revocation as + /// [`Error::Session`], [`Error::Cancel`] when the peer closed it without one, or the + /// handle's end. A session ending is not one: the next session presents it again. #[cfg(feature = "async")] pub async fn closed(&self) -> Error { - let Presented { state, id } = &*self.presented; + let selector = self.presented.selector(); kio::wait(|waiter| { - 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 state = ready_or!(self.presented.state.poll(waiter, |state| { + match selector.ended(state) { + Some(_) => Poll::Ready(()), + None => Poll::Pending, + } })); - let err = state.tokens.get(id).and_then(|slot| slot.ended.clone()); - Poll::Ready(err.unwrap_or(Error::Cancel)) + Poll::Ready(selector.ended(&state).unwrap_or(Error::Cancel)) }) .await } + /// Poll for the first verdict on a handle's token: a grant on any session, or the + /// refusal or end that settles it. #[cfg_attr(not(feature = "async"), allow(dead_code))] fn poll_answered(&self, waiter: &kio::Waiter) -> Poll> { - 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 id = self.presented.id; + let answered = |state: &State| match state.tokens.get(&id) { + Some(token) => token.answered.clone(), + None => Some(Err(Error::Cancel)), + }; + let state = ready_or!(self.presented.state.poll(waiter, |state| match answered(state) { + Some(_) => Poll::Ready(()), + None => Poll::Pending, })); - let answered = state.tokens.get(id).and_then(|slot| slot.answered.clone()); - Poll::Ready(answered.unwrap_or(Err(Error::Cancel))) + Poll::Ready(answered(&state).unwrap_or(Err(Error::Cancel))) } } @@ -863,26 +1367,64 @@ impl std::fmt::Debug for Auth { 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 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(); + let mut guard = self.state.lock(); + let state = &mut *guard; + match self.link { + Some(link) => { + if let Some(link) = state.links.get_mut(&link) { + link.withdraw(self.id); + } + } + None => { + state.tokens.remove(&self.id); + for link in state.links.values_mut() { + link.withdraw(self.id); + } + } } } } +/// Which grant a [`Watch`] follows. +#[derive(Clone, Copy)] enum Selector { + /// The live session's union. Union, - Slot(u64), + /// A handle's token, on the live session. + Token(u64), + /// A session's own credential, on that session. + Setup { link: u64, id: u64 }, } -/// Watches a grant: the session's union, or one credential's. +impl Selector { + fn current<'a>(&self, state: &'a State) -> Option<&'a Grant> { + match *self { + Self::Union => state.live()?.union.as_ref(), + Self::Token(id) => state.live()?.slots.get(&id)?.grant.as_ref(), + Self::Setup { link, id } => state.links.get(&link)?.slots.get(&id)?.grant.as_ref(), + } + } + + /// Why nothing will change the grant again, once nothing will. + fn ended(&self, state: &State) -> Option { + if let Some(err) = &state.closed { + return Some(err.clone()); + } + match *self { + Self::Union => None, + Self::Token(id) => match state.tokens.get(&id) { + Some(token) => token.ended.clone(), + None => Some(Error::Cancel), + }, + Self::Setup { link, id } => match state.links.get(&link).and_then(|link| link.slots.get(&id)) { + Some(slot) => slot.ended.clone(), + None => Some(Error::Cancel), + }, + } + } +} + +/// Watches a grant: the live session's union, or one credential's. /// /// Created by [`Handle::grant`] and [`Auth::grant`]. pub struct Watch { @@ -902,61 +1444,31 @@ impl Watch { watch } - fn current(&self, state: &State) -> Option { - match self.selector { - Selector::Union => state.union.clone(), - Selector::Slot(id) => state.tokens.get(&id).and_then(|slot| slot.grant.clone()), - } - } - - /// Whether nothing will change the grant again. - fn ended(&self, state: &State) -> Option { - if let Some(err) = state.closed() { - return Some(err); - } - match self.selector { - Selector::Union => None, - Selector::Slot(id) => match state.tokens.get(&id) { - Some(slot) => slot.ended.clone(), - None => Some(Error::Cancel), - }, - } - } - /// The grant right now. pub fn peek(&self) -> Option { - self.current(&self.state.read()) + self.selector.current(&self.state.read()).cloned() } /// Poll for the grant to differ from the last one this watch returned (or saw /// at creation). /// - /// `Err` once nothing will change it again: the session closed, or for a - /// token's watch, the token ended. + /// `Err` once nothing will change it again: the handle ended, or for a + /// token's watch, the token ended for good. pub fn poll_changed(&mut self, waiter: &kio::Waiter) -> Poll>> { let last = &self.last; - let selector = &self.selector; + let selector = self.selector; let state = ready_or!(self.state.poll(waiter, |state| { - let current = match selector { - Selector::Union => state.union.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::Slot(id) => state.tokens.get(id).is_none_or(|slot| slot.ended.is_some()), - }; - match current != last.as_ref() || ended { + match selector.current(state) != last.as_ref() || selector.ended(state).is_some() { true => Poll::Ready(()), false => Poll::Pending, } })); - let current = self.current(&state); + let current = selector.current(&state).cloned(); if current != self.last { self.last = current.clone(); return Poll::Ready(Ok(current)); } - Poll::Ready(Err(self.ended(&state).unwrap_or(Error::Cancel))) + Poll::Ready(Err(selector.ended(&state).unwrap_or(Error::Cancel))) } /// Wait for the grant to change, returning the new one. @@ -1084,7 +1596,7 @@ pub(crate) struct Issue { taken: bool, /// An AUTH token's place in the session's scope, which its grant joins. A token that rode /// on a request has none: its grant covers only that request. - scoped: Option<(Handle, u64)>, + scoped: Option<(Link, u64)>, } impl Issue { @@ -1121,15 +1633,15 @@ pub(crate) type Hold = std::sync::Arc; #[cfg_attr(not(feature = "async"), allow(dead_code))] pub(crate) struct Serving { pub issue: kio::Shared, - handle: Handle, + handle: Link, } impl Serving { /// A fresh stream's shared state, settled by the session `handle` if the task drops. #[cfg_attr(not(feature = "async"), allow(dead_code))] - pub(crate) fn new(handle: Handle) -> Self { + pub(crate) fn new(handle: Link) -> Self { let id = { - let mut state = handle.state.lock(); + let mut state = handle.state().lock(); state.next_issue += 1; state.next_issue }; @@ -1156,7 +1668,7 @@ impl Serving { impl Drop for Serving { fn drop(&mut self) { - let err = self.handle.state.read().closed().unwrap_or(Error::Cancel); + let err = self.handle.closed().unwrap_or(Error::Cancel); self.end(err); } } @@ -1264,7 +1776,7 @@ impl RequestKind { } } -/// The verdict on a request-borne token, from [`Handle::verify_request`]. +/// The verdict on a request-borne token, from [`Link::verify_request`]. /// /// Holding it keeps the request's grant alive; dropping it tells the acceptor the request /// is over, so the grant lives exactly as long as the request and never outlives it. @@ -1447,7 +1959,7 @@ 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 { + pub(crate) fn verify_renewal(&self, handle: &Link, token: crate::setup::Token) -> RequestVerdict { let mut held = self.held(); let verdict = handle.verify_request(token, held.hold.clone()); held.renewing = verdict.issue.clone(); @@ -1639,7 +2151,7 @@ pub(crate) enum Direction { /// only re-matched when the union or the limit changes. #[cfg_attr(not(feature = "async"), allow(dead_code))] pub(crate) struct Gate { - handle: Handle, + handle: Link, path: crate::PathOwned, direction: Direction, epoch: u64, @@ -1675,7 +2187,7 @@ enum Part { impl Gate { #[cfg_attr(not(feature = "async"), allow(dead_code))] - pub(crate) fn new(handle: Handle, path: crate::PathOwned, direction: Direction) -> Self { + pub(crate) fn new(handle: Link, path: crate::PathOwned, direction: Direction) -> Self { let within_limit = handle.within_limit(direction, path.as_str()); Self { handle, @@ -1700,7 +2212,7 @@ impl Gate { /// the announce stands in for the scope, as its own token may have authorized it. #[cfg_attr(not(feature = "async"), allow(dead_code))] pub(crate) fn announced( - handle: Handle, + handle: Link, path: crate::PathOwned, direction: Direction, live: kio::Consumer<()>, @@ -1814,7 +2326,7 @@ impl Gate { /// the session's origin handles allow, within the limit as it changes. #[cfg_attr(not(feature = "async"), allow(dead_code))] pub(crate) struct DefaultGrant { - handle: Handle, + handle: Link, base: Grant, epoch: u64, limit: Option, @@ -1824,8 +2336,8 @@ pub(crate) struct DefaultGrant { impl DefaultGrant { #[cfg_attr(not(feature = "async"), allow(dead_code))] - pub(crate) fn new(handle: Handle, base: Grant) -> Self { - let limit = handle.state.read().limit.clone(); + pub(crate) fn new(handle: Link, base: Grant) -> Self { + let limit = handle.state().read().limit.clone(); Self { handle, base, @@ -1888,7 +2400,7 @@ impl Enforce { #[cfg_attr(not(feature = "async"), allow(dead_code))] pub(crate) fn poll( &mut self, - handle: &Handle, + handle: &Link, announced: &mut crate::announce::Consumer, waiter: &kio::Waiter, ) -> Poll> { @@ -1956,7 +2468,7 @@ mod tests { #[moq_clock::test] async fn a_request_waits_for_an_unanswered_token() { - let handle = Handle::new(true); + let handle = Link::new(true); let setup = handle.present(Bytes::new(), true).unwrap(); handle.granted(setup.presented.id, Grant::default()); let token = handle.present(Bytes::from_static(b"new"), false).unwrap(); @@ -1979,7 +2491,7 @@ mod tests { /// is refused against the union as it stands. #[test] fn a_withdrawn_route_ends_the_wait() { - let handle = Handle::new(true); + let handle = Link::new(true); let setup = handle.present(Bytes::new(), true).unwrap(); handle.granted(setup.presented.id, Grant::default()); let _unanswered = handle.present(Bytes::from_static(b"new"), false).unwrap(); @@ -1999,7 +2511,7 @@ mod tests { #[test] fn an_empty_token_never_holds_a_request() { - let handle = Handle::new(true); + let handle = Link::new(true); let _setup = handle.present(Bytes::new(), true).unwrap(); let mut gate = Gate::new(handle, crate::Path::new("room").to_owned(), Direction::Subscribe); assert!(gate.poll_ready(&kio::Waiter::noop()).is_ready()); @@ -2007,7 +2519,7 @@ mod tests { #[test] fn a_covered_request_never_waits_for_an_unanswered_token() { - let handle = Handle::new(true); + let handle = Link::new(true); let first = handle.present(Bytes::from_static(b"first"), false).unwrap(); handle.granted(first.presented.id, Grant::all()); let _unanswered = handle.present(Bytes::from_static(b"renewal"), false).unwrap(); @@ -2017,8 +2529,8 @@ mod tests { #[test] fn an_auth_ok_is_queued_after_its_scope_widens() { - let handle = Handle::new(true); - handle.authorize(&Grant::default()); + let handle = Link::new(true); + handle.handle().authorize(&Grant::default()); let serving = Serving::new(handle.clone()); let request = Request::new(Bytes::from_static(b"token"), serving.issue.clone()); let _issued = request.accept(Grant::all()); @@ -2031,8 +2543,8 @@ mod tests { /// how many wait; answering it gives the slot back. #[test] fn a_queued_request_token_holds_its_slot() { - let handle = Handle::new(true); - let mut requests = handle.requests().unwrap(); + let handle = Link::new(true); + let mut requests = handle.handle().requests().unwrap(); let slot: Hold = std::sync::Arc::new(()); let token = crate::setup::Token { kind: crate::setup::Token::OUT_OF_BAND, @@ -2060,8 +2572,8 @@ mod tests { /// withdrawing cannot grow it past what its credit holds open. #[test] fn withdrawn_tokens_leave_the_queue() { - let handle = Handle::new(true); - let mut requests = handle.requests().unwrap(); + let handle = Link::new(true); + let mut requests = handle.handle().requests().unwrap(); let queue = handle.acceptor().unwrap(); queue.push(Request::new(Bytes::from_static(b"live"), Default::default())); let withdrawn = kio::Shared::::default(); @@ -2096,22 +2608,22 @@ mod tests { /// without AUTH too. #[test] fn a_limit_replaces_the_last() { - let handle = Handle::new(false); + let handle = Link::new(false); assert!(handle.allows(Direction::Publish, "room/alice/audio")); - handle.authorize(&grant(&["room/bob"], &["room/alice"])); + handle.handle().authorize(&grant(&["room/bob"], &["room/alice"])); // What we send is what the peer may subscribe to, and the other way around. assert!(handle.allows(Direction::Publish, "room/alice/audio")); assert!(!handle.allows(Direction::Publish, "room/bob/cam")); assert!(handle.allows(Direction::Subscribe, "room/bob/cam")); assert!(!handle.allows(Direction::Subscribe, "room/alice/audio")); - handle.authorize(&grant(&[""], &["room/alice/video"])); + handle.handle().authorize(&grant(&[""], &["room/alice/video"])); assert!(!handle.allows(Direction::Publish, "room/alice/audio")); assert!(handle.allows(Direction::Publish, "room/alice/video")); assert!(handle.allows(Direction::Subscribe, "other")); - handle.authorize(&Grant::all()); + handle.handle().authorize(&Grant::all()); assert!(handle.allows(Direction::Publish, "room/alice/audio")); assert!(handle.allows(Direction::Subscribe, "other")); } @@ -2119,13 +2631,13 @@ mod tests { /// Our own grant does not decide what the peer may offer us, only the limit does. #[test] fn the_limit_alone_decides_what_the_peer_offers() { - let handle = Handle::new(true); + let handle = Link::new(true); let token = handle.present(Bytes::new(), true).unwrap(); handle.granted(0, grant(&[], &[])); assert!(!handle.allows(Direction::Subscribe, "room/bob/cam")); assert!(handle.within_limit(Direction::Subscribe, "room/bob/cam")); - handle.authorize(&grant(&["room/alice"], &[])); + handle.handle().authorize(&grant(&["room/alice"], &[])); assert!(!handle.within_limit(Direction::Subscribe, "room/bob/cam")); assert!(handle.within_limit(Direction::Subscribe, "room/alice/cam")); drop(token); @@ -2135,22 +2647,22 @@ mod tests { /// limit changes, narrower or wider, and stays quiet on a change that leaves it alone. #[test] fn the_default_grant_follows_the_limit() { - let handle = Handle::new(true); + let handle = Link::new(true); let mut default = DefaultGrant::new(handle.clone(), grant(&["room"], &["room"])); let waiter = kio::Waiter::noop(); assert_eq!(default.poll(&waiter), Poll::Ready(grant(&["room"], &["room"]))); assert!(default.poll(&waiter).is_pending()); - handle.authorize(&grant(&["room"], &["room/alice"])); + handle.handle().authorize(&grant(&["room"], &["room/alice"])); assert_eq!(default.poll(&waiter), Poll::Ready(grant(&["room"], &["room/alice"]))); // Wider again, but never past what the origin handles allow. - handle.authorize(&Grant::all()); + handle.handle().authorize(&Grant::all()); assert_eq!(default.poll(&waiter), Poll::Ready(grant(&["room"], &["room"]))); // A limit that leaves the grant as it is: nothing to tell the peer. - handle.authorize(&grant(&[""], &[""])); + handle.handle().authorize(&grant(&[""], &[""])); assert!(default.poll(&waiter).is_pending()); } @@ -2159,17 +2671,18 @@ mod tests { /// could carry it. #[test] fn a_request_carries_an_auth_without_the_auth_stream() { - let ietf = Handle::ietf(false); + let ietf = Link::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"); + assert!(ietf.read(|link, _| link.opening.is_empty()), "nothing to open a stream for"); - let lite = Handle::new(false); + let lite = Link::new(false); + let auth = lite.present(Bytes::from_static(b"jwt"), false).unwrap(); assert!(matches!( - lite.present(Bytes::from_static(b"jwt"), false), - Err(Error::Unsupported) + auth.poll_answered(&kio::Waiter::noop()), + Poll::Ready(Err(Error::Unsupported)) )); } @@ -2177,7 +2690,7 @@ mod tests { /// requests rather than failing it. #[test] fn an_unsupported_peer_keeps_a_request_auth() { - let handle = Handle::ietf(true); + let handle = Link::ietf(true); let auth = handle.present(Bytes::from_static(b"jwt"), false).unwrap(); assert!(auth.poll_answered(&kio::Waiter::noop()).is_pending()); handle.unsupported(); @@ -2189,15 +2702,15 @@ mod tests { /// auth goes out under this session's scope. #[test] fn another_handle_does_not_carry_an_auth() { - let issuer = Handle::ietf(false); + let issuer = Link::ietf(false); let auth = issuer.present(Bytes::from_static(b"jwt"), false).unwrap(); - assert_eq!(Handle::ietf(false).token(&auth), None); + assert_eq!(Link::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 handle = Link::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"); @@ -2218,6 +2731,68 @@ mod tests { drop(clone); assert!(handle.poll_withdrawn(id, &kio::Waiter::noop()).is_ready()); } + + fn opened(link: &Link) -> u64 { + match link.poll_opening(&kio::Waiter::noop()) { + Poll::Ready(Some((id, _))) => id, + _ => panic!("nothing to open"), + } + } + + /// Only the peer's verdict ends a handle's token for good: a session ending leaves it for + /// the next session, which presents it again, and one active session at a time. + #[test] + fn only_a_verdict_ends_a_shared_token() { + let handle = Handle::new(); + let first = Link::attach(&handle, true, false).unwrap(); + assert!(matches!(Link::attach(&handle, true, false), Err(Error::Duplicate))); + let auth = handle.present(Bytes::from_static(b"jwt")).unwrap(); + let id = opened(&first); + first.granted(id, Grant::all()); + assert!(matches!(auth.poll_answered(&kio::Waiter::noop()), Poll::Ready(Ok(())))); + assert_eq!(auth.grant().peek(), Some(Grant::all())); + + first.close(Error::Cancel); + assert_eq!(auth.grant().peek(), None, "no session is live"); + assert!(Selector::Token(id).ended(&handle.state.read()).is_none()); + + let second = Link::attach(&handle, true, false).unwrap(); + assert_eq!(opened(&second), id, "the next session presents it again"); + second.refused(id); + let refused = Error::Session(SessionError::Unauthorized); + assert_eq!(second.retry(id, &refused), None); + second.ended(id, refused); + assert!(matches!( + Selector::Token(id).ended(&handle.state.read()), + Some(Error::Session(SessionError::Unauthorized)) + )); + + second.close(Error::Cancel); + let third = Link::attach(&handle, true, false).unwrap(); + assert!(third.poll_opening(&kio::Waiter::noop()).is_pending(), "a refused token stays ended"); + } + + /// A refusal that may pass is presented again after a doubling backoff, and settles nothing. + #[test] + fn a_transient_refusal_backs_off() { + let link = Link::new(true); + let auth = link.present(Bytes::from_static(b"jwt"), false).unwrap(); + let id = opened(&link); + let busy = Error::Session(SessionError::TooManyRequests); + + link.refused(id); + assert_eq!(link.retry(id, &busy), Some(RETRY_INITIAL)); + assert!(auth.poll_answered(&kio::Waiter::noop()).is_pending()); + assert_eq!(link.reopen(id).as_deref(), Some(b"jwt".as_slice())); + link.refused(id); + assert_eq!(link.retry(id, &busy), Some(RETRY_INITIAL * 2)); + assert_eq!(link.retry(id, &busy), None, "only a refusal retries"); + + link.reopen(id); + link.granted(id, Grant::all()); + assert!(matches!(auth.poll_answered(&kio::Waiter::noop()), Poll::Ready(Ok(())))); + } + } #[cfg(all(test, feature = "async"))] @@ -2239,8 +2814,8 @@ mod request_token_tests { /// app answers is what the verdict resolves to. #[moq_clock::test] async fn a_request_token_reaches_the_acceptor() { - let handle = Handle::new(true); - let mut requests = handle.requests().expect("take the requests"); + let handle = Link::new(true); + let mut requests = handle.handle().requests().expect("take the requests"); let verdict = handle.verify_request(token(b"jwt", 0), None); let request = requests.next().await.expect("a request"); @@ -2255,8 +2830,8 @@ mod request_token_tests { /// the acceptor as one: it is unsupported, never handed over as if it were a plain token. #[moq_clock::test] async fn a_typed_request_token_is_unsupported() { - let handle = Handle::new(true); - let mut requests = handle.requests().unwrap(); + let handle = Link::new(true); + let mut requests = handle.handle().requests().unwrap(); let verdict = handle.verify_request(token(b"cat", crate::setup::Token::CAT), None); let err = verdict.grant().await.expect_err("a typed token"); assert!(matches!(err.error, Error::Unsupported), "{err:?}"); @@ -2270,8 +2845,8 @@ mod request_token_tests { /// connection's own credential. #[test] fn an_empty_request_token_is_unsupported() { - let handle = Handle::new(true); - let mut requests = handle.requests().unwrap(); + let handle = Link::new(true); + let mut requests = handle.handle().requests().unwrap(); let verdict = handle.verify_request(token(b"", crate::setup::Token::OUT_OF_BAND), None); assert!(matches!( verdict.poll_grant(&kio::Waiter::noop()), @@ -2292,8 +2867,8 @@ mod request_token_tests { /// a standard moq-transport peer presents when it does not negotiate the AUTH extension. #[moq_clock::test] async fn a_request_token_is_verified_without_the_auth_extension() { - let handle = Handle::new(false); - let mut requests = handle.requests().expect("take the requests"); + let handle = Link::new(false); + let mut requests = handle.handle().requests().expect("take the requests"); handle.unsupported(); let verdict = handle.verify_request(token(b"jwt", 0), None); let request = requests.next().await.expect("a request reaches the acceptor"); @@ -2308,7 +2883,7 @@ mod request_token_tests { /// is Unsupported and the caller refuses the request NOT_SUPPORTED. #[moq_clock::test] async fn no_consumer_refuses_a_request_token_as_unsupported() { - let handle = Handle::new(true); + let handle = Link::new(true); let verdict = handle.verify_request(token(b"jwt", 0), None); let err = verdict.grant().await.expect_err("no verifier"); assert!(matches!(err.error, Error::Unsupported), "{err:?}"); @@ -2318,8 +2893,8 @@ mod request_token_tests { /// UNAUTHORIZED. #[moq_clock::test] async fn a_refused_request_token_is_unauthorized() { - let handle = Handle::new(true); - let mut requests = handle.requests().unwrap(); + let handle = Link::new(true); + let mut requests = handle.handle().requests().unwrap(); let verdict = handle.verify_request(token(b"jwt", 0), None); requests.next().await.unwrap().reject(SessionError::Unauthorized, "no"); let err = verdict.grant().await.expect_err("refused"); @@ -2333,8 +2908,8 @@ mod request_token_tests { /// as it stands now, never as first issued. #[moq_clock::test] async fn a_grant_ended_before_admission_is_refused() { - let handle = Handle::new(true); - let mut requests = handle.requests().unwrap(); + let handle = Link::new(true); + let mut requests = handle.handle().requests().unwrap(); let verdict = handle.verify_request(token(b"jwt", 0), None); let issued = requests.next().await.unwrap().accept(Grant::all()); @@ -2364,8 +2939,8 @@ mod request_token_tests { /// acceptor's issued grant, so it never outlives the request nor joins the union. #[moq_clock::test] async fn dropping_the_verdict_ends_the_grant() { - let handle = Handle::new(true); - let mut requests = handle.requests().unwrap(); + let handle = Link::new(true); + let mut requests = handle.handle().requests().unwrap(); let verdict = handle.verify_request(token(b"jwt", 0), None); let issued = requests.next().await.unwrap().accept(Grant::all()); verdict.grant().await.expect("granted"); @@ -2378,14 +2953,14 @@ mod request_token_tests { /// credential earned, not what a request token granted. #[moq_clock::test] async fn a_request_token_never_joins_the_union() { - let handle = Handle::new(true); - let mut requests = handle.requests().unwrap(); + let handle = Link::new(true); + let mut requests = handle.handle().requests().unwrap(); let verdict = handle.verify_request(token(b"jwt", 0), None); let _issued = requests.next().await.unwrap().accept(Grant::all()); verdict.grant().await.expect("granted"); // The union only reflects tokens presented on this side (none here), never a // request token the acceptor answered. - assert_eq!(handle.grant().peek(), None, "a request token must not widen the union"); + assert_eq!(handle.handle().grant().peek(), None, "a request token must not widen the union"); } fn subscribe_only(prefix: &str) -> Grant { @@ -2401,9 +2976,9 @@ mod request_token_tests { /// request never widens what tokenless requests may do. #[moq_clock::test] async fn the_scope_is_session_tokens_only() { - let handle = Handle::new(true); - let mut requests = handle.requests().unwrap(); - handle.authorize(&subscribe_only("room/alice")); + let handle = Link::new(true); + let mut requests = handle.handle().requests().unwrap(); + handle.handle().authorize(&subscribe_only("room/alice")); assert!(!handle.allows(Direction::Publish, "room/bob")); // A token on a request admits that request, and nothing else. @@ -2447,9 +3022,9 @@ mod request_token_tests { /// Revoking or dropping an AUTH token's grant takes it out of the scope. #[moq_clock::test] async fn an_ended_auth_grant_leaves_the_scope() { - let handle = Handle::new(true); - let mut requests = handle.requests().unwrap(); - handle.authorize(&Grant::default()); + let handle = Link::new(true); + let mut requests = handle.handle().requests().unwrap(); + handle.handle().authorize(&Grant::default()); for revoke in [true, false] { let serving = Serving::new(handle.clone()); requests @@ -2469,8 +3044,8 @@ mod request_token_tests { /// tokens were granted: the scope only exists once the connection credential has a grant. #[moq_clock::test] async fn an_unauthorized_session_has_no_scope() { - let handle = Handle::new(true); - let mut requests = handle.requests().unwrap(); + let handle = Link::new(true); + let mut requests = handle.handle().requests().unwrap(); let serving = Serving::new(handle.clone()); requests .queue @@ -2493,8 +3068,8 @@ mod request_token_tests { #[moq_clock::test] async fn a_request_grant_has_the_acceptors_expiry() { let runtime = crate::time::sim(); - let handle = Handle::new(true); - let mut requests = handle.requests().unwrap(); + let handle = Link::new(true); + let mut requests = handle.handle().requests().unwrap(); let verdict = handle.verify_request(token(b"jwt", 0), None); let grant = grant_in(&runtime, 60); let expires = grant.expires; @@ -2518,8 +3093,8 @@ mod request_token_tests { #[moq_clock::test] async fn an_already_expired_grant_is_refused() { let runtime = crate::time::sim(); - let handle = Handle::new(true); - let mut requests = handle.requests().unwrap(); + let handle = Link::new(true); + let mut requests = handle.handle().requests().unwrap(); let expired = || Grant { expires: Some(runtime.now()), ..grant_in(&runtime, 0) @@ -2566,8 +3141,8 @@ mod request_token_tests { #[moq_clock::test] async fn a_renewal_token_replaces_the_grant_and_expiry() { let runtime = crate::time::sim(); - let handle = Handle::new(true); - let mut requests = handle.requests().unwrap(); + let handle = Link::new(true); + let mut requests = handle.handle().requests().unwrap(); let verdict = handle.verify_request(token(b"jwt", 0), None); let first = grant_in(&runtime, 60); @@ -2604,8 +3179,8 @@ mod request_token_tests { #[moq_clock::test] async fn the_deadline_expires_the_request_without_renewal() { let runtime = crate::time::sim(); - let handle = Handle::new(true); - let mut requests = handle.requests().unwrap(); + let handle = Link::new(true); + let mut requests = handle.handle().requests().unwrap(); let verdict = handle.verify_request(token(b"jwt", 0), None); // Held for the request's life, so only the deadline (not a drop) ends it. let _issued = requests.next().await.unwrap().accept(grant_in(&runtime, 60)); @@ -2622,7 +3197,7 @@ mod request_token_tests { let err = kio::wait(|waiter| request_grant.poll_ended(waiter)).await.error; assert!(matches!(err, Error::Expired), "{err:?}"); - assert_eq!(handle.grant().peek(), None, "the session grant is untouched"); + assert_eq!(handle.handle().grant().peek(), None, "the session grant is untouched"); } /// A renewal the acceptor granted before the old deadline lapsed keeps the request, even @@ -2630,8 +3205,8 @@ mod request_token_tests { #[moq_clock::test] async fn a_granted_renewal_outlives_the_old_deadline() { let runtime = crate::time::sim(); - let handle = Handle::new(true); - let mut requests = handle.requests().unwrap(); + let handle = Link::new(true); + let mut requests = handle.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(); @@ -2668,8 +3243,8 @@ mod request_token_tests { #[moq_clock::test] async fn a_refused_renewal_leaves_the_grant() { let runtime = crate::time::sim(); - let handle = Handle::new(true); - let mut requests = handle.requests().unwrap(); + let handle = Link::new(true); + let mut requests = handle.handle().requests().unwrap(); let verdict = handle.verify_request(token(b"jwt", 0), None); let first = grant_in(&runtime, 60); let first_expires = first.expires; @@ -2714,8 +3289,8 @@ mod request_token_tests { #[moq_clock::test] async fn an_update_that_stops_covering_ends_the_request() { let runtime = crate::time::sim(); - let handle = Handle::new(true); - let mut requests = handle.requests().unwrap(); + let handle = Link::new(true); + let mut requests = handle.handle().requests().unwrap(); let verdict = handle.verify_request(token(b"jwt", 0), None); let issued = requests.next().await.unwrap().accept(Grant::all()); let answered = verdict.grant().await.unwrap(); @@ -2797,8 +3372,8 @@ mod request_token_tests { #[moq_clock::test] async fn an_acceptor_revoke_ends_the_request() { let runtime = crate::time::sim(); - let handle = Handle::new(true); - let mut requests = handle.requests().unwrap(); + let handle = Link::new(true); + let mut requests = handle.handle().requests().unwrap(); let verdict = handle.verify_request(token(b"jwt", 0), None); // A grant that never expires, so only the revoke can end the request. let issued = requests.next().await.unwrap().accept(Grant::all()); @@ -2820,6 +3395,6 @@ mod request_token_tests { "{refusal:?}" ); assert_eq!(refusal.reason, "revoked", "the acceptor's reason is kept"); - assert_eq!(handle.grant().peek(), None, "the session grant is untouched"); + assert_eq!(handle.handle().grant().peek(), None, "the session grant is untouched"); } } diff --git a/rs/moq-net/src/client.rs b/rs/moq-net/src/client.rs index fa06045ed8b..a9cb91c1505 100644 --- a/rs/moq-net/src/client.rs +++ b/rs/moq-net/src/client.rs @@ -20,6 +20,7 @@ pub struct Client { peer_hop: Option, limits: crate::session::Limits, extensions: setup::Extensions, + auth: Option, } impl Client { @@ -95,6 +96,18 @@ impl Client { self } + /// Build every session on `auth`, so each presents the tokens it holds and a route + /// naming one of its [`Auth`](crate::auth::Auth)s keeps it across a reconnect. Omit to + /// give each session a handle of its own, which ends with it. + /// + /// The handle backs one active session at a time, plus the ones draining after a + /// GOAWAY: connecting another while one is still active fails with + /// [`Error::Duplicate`]. + pub fn with_auth(mut self, auth: crate::auth::Handle) -> Self { + self.auth = Some(auth); + self + } + /// Choose which extensions to offer in SETUP. Defaults to every moq-transport one, and /// not [`ietf`](setup::Extensions::ietf), which only relays turn on. pub fn with_extensions(mut self, extensions: setup::Extensions) -> Self { @@ -151,6 +164,16 @@ impl Client { self.peer_hop.unwrap_or_else(crate::Hop::random) } + /// The new session's side of its auth handle: the one from [`with_auth`](Self::with_auth), + /// or one of its own. `requests` for moq-transport, where a request carries a token. + fn link(&self, supported: bool, requests: bool) -> Result { + match &self.auth { + Some(handle) => crate::auth::Link::attach(handle, supported, requests), + None if requests => Ok(crate::auth::Link::ietf(supported)), + None => Ok(crate::auth::Link::new(supported)), + } + } + /// The origin pair a session attaches, tagged and filtered. /// /// Reads through the publish (egress) consumer and writes through the @@ -219,7 +242,7 @@ impl Client { version, our_setup, peer_setup: None, - auth: crate::auth::Handle::new(version.has_auth()), + auth: self.link(version.has_auth(), false)?, })?; Ok(Session::new( @@ -293,7 +316,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::ietf(self.extensions.auth); + let auth = self.link(self.extensions.auth, true)?; let (protocol, goaway) = ietf::start(ietf::Config { runtime: runtime.clone(), limits: self.limits, @@ -439,7 +462,7 @@ impl Client { our_setup: lite::Setup::default(), peer_setup: None, // Negotiated over the bidi SETUP: lite 01/02, which carry no AUTH. - auth: crate::auth::Handle::new(v.has_auth()), + auth: self.link(v.has_auth(), false)?, })?; ( @@ -467,7 +490,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::ietf(false); + let auth = self.link(false, true)?; // 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/goaway.rs b/rs/moq-net/src/goaway.rs index f1659e60c9c..f99b17b0961 100644 --- a/rs/moq-net/src/goaway.rs +++ b/rs/moq-net/src/goaway.rs @@ -398,6 +398,11 @@ impl Handle { pub fn consumer(&self) -> Consumer { self.consumer.clone() } + + /// Whether either side sent a GOAWAY, so the session is draining. + pub fn draining(&self) -> bool { + self.trigger.read().is_some() || self.consumer.state.read().is_some() + } } #[cfg(test)] diff --git a/rs/moq-net/src/ietf/auth.rs b/rs/moq-net/src/ietf/auth.rs index 71ecc4097f0..9394dc41b7e 100644 --- a/rs/moq-net/src/ietf/auth.rs +++ b/rs/moq-net/src/ietf/auth.rs @@ -13,7 +13,7 @@ use std::time::Duration; use bytes::Bytes; -use crate::auth::{Grant, Handle, Issue, Reply, Request, Serving}; +use crate::auth::{Grant, Issue, Link, Reply, Request, Serving}; use crate::coding::{Decode, DecodeError, Decoder, Encode, EncodeError, Encoder}; use crate::{Error, Path, Pattern, Patterns, SessionError}; @@ -257,7 +257,7 @@ pub(super) async fn run_present( runtime: crate::time::Deadlines, session: S, control: Control, - handle: Handle, + handle: Link, peer_setup: peer::PeerSetup, version: Version, going_away: crate::goaway::GoingAway, @@ -295,10 +295,27 @@ where let going_away = going_away.clone(); let violation = violation.clone(); tasks.push(async move { - // After a GOAWAY the peer must not see new requests. - let err = match going_away.is_set() { - true => Error::GoingAway, - false => present.run(token).await, + let mut token = token; + let err = loop { + // After a GOAWAY the peer must not see new requests. + if going_away.is_set() { + break Error::GoingAway; + } + let err = present.run(token).await; + let Some(delay) = present.handle.retry(present.id, &err) else { + break err; + }; + tracing::debug!(?delay, "auth token refused transiently; presenting it again"); + let mut deadline = crate::time::Deadline::after(&present.runtime, delay); + kio::wait(|waiter| match present.handle.poll_withdrawn(present.id, waiter) { + Poll::Ready(()) => Poll::Ready(()), + Poll::Pending => deadline.poll(waiter), + }) + .await; + match present.handle.reopen(present.id) { + Some(next) => token = next, + None => break Error::Cancel, + } }; let err = crate::auth::protocol_error(err); if matches!(err, Error::ProtocolViolation) { @@ -323,7 +340,7 @@ struct Present { runtime: crate::time::Deadlines, session: S, control: Control, - handle: Handle, + handle: Link, version: Version, id: u64, } @@ -433,7 +450,7 @@ fn decode_reply(body: &super::Body, version: Version) -> Result { version: Version, // Our grant (MoQ Auth): only what it lets us publish is advertised and served, and a // shrink withdraws what it no longer covers. - auth: crate::auth::Handle, + auth: crate::auth::Link, // Dispatched finite serves, including those not yet polled. pub(super) owed: Arc, // Subscriptions the peer may hold at once (`session::Limits::subscriptions`). @@ -664,7 +664,7 @@ pub(super) struct Publisher { /// Authorization selected by dispatch: a standalone path or a live parent subscription. enum FetchGate { Standalone(crate::auth::Gate), - Joining { id: RequestId, auth: crate::auth::Handle }, + Joining { id: RequestId, auth: crate::auth::Link }, } struct Serve(Arc); @@ -735,7 +735,7 @@ where peer_setup, joins: Default::default(), version, - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), owed: Default::default(), subscriptions: Default::default(), fetches: Default::default(), @@ -744,7 +744,7 @@ where } /// Bound what we publish by the grant this session's tokens earn (MoQ Auth). - pub fn with_auth(mut self, auth: crate::auth::Handle) -> Self { + pub fn with_auth(mut self, auth: crate::auth::Link) -> Self { self.auth = auth; self } @@ -7536,7 +7536,7 @@ mod serve_tests { )); assert!(futures::poll!(running.as_mut()).is_pending()); let mark = session.log.writes.lock().unwrap().len(); - h.publisher.auth.authorize(&crate::auth::Grant::default()); + h.publisher.auth.handle().authorize(&crate::auth::Grant::default()); // An otherwise valid no-op update is ready at the same time as revocation. session.push(&[0x02, 0, 2, 2, 0]); assert!(futures::poll!(running.as_mut()).is_pending()); @@ -8073,8 +8073,8 @@ mod serve_tests { for version in ALL_VERSIONS { let mut h = serve(version); let claim = h._origin.dynamic("live", crate::origin::Route::default()).unwrap(); - let auth = crate::auth::Handle::new(false); - auth.authorize(¬hing()); + let auth = crate::auth::Link::new(false); + auth.handle().authorize(¬hing()); h.publisher = h.publisher.clone().with_auth(auth); let code = moq_clock::timeout( std::time::Duration::from_secs(1), @@ -8093,7 +8093,7 @@ mod serve_tests { for version in ALL_VERSIONS { let mut h = serve(version); let claim = h._origin.dynamic("live", crate::origin::Route::default()).unwrap(); - let auth = crate::auth::Handle::new(false); + let auth = crate::auth::Link::new(false); h.publisher = h.publisher.clone().with_auth(auth.clone()); let stream = Stream::open(&mut h.session.clone(), version).await.unwrap(); @@ -8114,7 +8114,7 @@ mod serve_tests { // Parked resolving the broadcast when the grant narrows. let request = claim.requested_broadcast().await.unwrap(); - auth.authorize(¬hing()); + auth.handle().authorize(¬hing()); let output = crate::broadcast::Info::new().produce(); let _track = output.create_track("video", None).unwrap(); request.accept(&output); @@ -8136,8 +8136,8 @@ mod serve_tests { }; for version in [Version::Draft14, Version::Draft22] { let mut h = serve(version); - let auth = crate::auth::Handle::new(false); - auth.authorize(¬hing); + let auth = crate::auth::Link::new(false); + auth.handle().authorize(¬hing); h.publisher = h.publisher.clone().with_auth(auth); let mut body = track_status("room", true, version); @@ -8164,7 +8164,7 @@ mod serve_tests { let session = SinkSession::gated_bi(bi.consume()); let peer_setup = peer::PeerSetup::default(); peer_setup.set(peer::Peer::default()); - let auth = crate::auth::Handle::new(false); + let auth = crate::auth::Link::new(false); let publisher = Publisher::new( crate::time::sim(), session.clone(), @@ -8199,7 +8199,7 @@ mod serve_tests { moq_clock::sim::yield_now().await; } - auth.authorize(&crate::auth::Grant { + auth.handle().authorize(&crate::auth::Grant { publish: Default::default(), subscribe: Default::default(), expires: None, @@ -10302,8 +10302,8 @@ mod serve_tests { let mut h = serve(version); publish_pairs(&mut h, 1, None); settle().await; - let auth = crate::auth::Handle::new(false); - auth.authorize(¬hing); + let auth = crate::auth::Link::new(false); + auth.handle().authorize(¬hing); h.publisher = h.publisher.clone().with_auth(auth); let buf = standalone_fetch(&h, location, location, GroupOrder::Ascending).await; @@ -10319,7 +10319,7 @@ mod serve_tests { let h = serve(version); let _group = h.track.clone().create_group(group::Info { sequence: 0 }).unwrap(); settle().await; - let auth = crate::auth::Handle::new(false); + let auth = crate::auth::Link::new(false); let mut h = h; h.publisher = h.publisher.clone().with_auth(auth.clone()); @@ -10333,7 +10333,7 @@ mod serve_tests { futures::poll!(fetch.as_mut()).is_pending(), "{version}: served an unwritten group" ); - auth.authorize(¬hing); + auth.handle().authorize(¬hing); let buf = moq_clock::timeout(std::time::Duration::from_secs(1), fetch) .await .unwrap_or_else(|_| panic!("{version}: still loading after the grant narrowed")); @@ -10364,7 +10364,7 @@ mod serve_tests { let session = SinkSession::gated_bi(bi.consume()).with_open_uni_gate(credit.consume()); let peer_setup = peer::PeerSetup::default(); peer_setup.set(peer::Peer::default()); - let auth = crate::auth::Handle::new(false); + let auth = crate::auth::Link::new(false); let publisher = Publisher::new( crate::time::sim(), session.clone(), @@ -10413,7 +10413,7 @@ mod serve_tests { "{version}: FETCH_OK never went out" ); - auth.authorize(&crate::auth::Grant { + auth.handle().authorize(&crate::auth::Grant { publish: Default::default(), subscribe: Default::default(), expires: None, @@ -10457,7 +10457,7 @@ mod serve_tests { let session = SinkSession::gated_bi(bi.consume()).with_open_uni_gate(credit.consume()); let peer_setup = peer::PeerSetup::default(); peer_setup.set(peer::Peer::default()); - let auth = crate::auth::Handle::new(false); + let auth = crate::auth::Link::new(false); let publisher = Publisher::new( crate::time::sim(), session.clone(), @@ -10515,7 +10515,7 @@ mod serve_tests { "{version}: FETCH_OK never went out" ); - auth.authorize(&crate::auth::Grant { + auth.handle().authorize(&crate::auth::Grant { publish: Default::default(), subscribe: Default::default(), expires: None, @@ -10561,10 +10561,10 @@ mod serve_tests { let session = SinkSession::gated_bi(bi.consume()).with_open_uni_gate(credit.consume()); let peer_setup = peer::PeerSetup::default(); peer_setup.set(peer::Peer::default()); - let auth = crate::auth::Handle::new(false); - let mut requests = auth.requests().unwrap(); + let auth = crate::auth::Link::new(false); + let mut requests = auth.handle().requests().unwrap(); // A limit of nothing, so only the token admits the subscription. - auth.authorize(&match token { + auth.handle().authorize(&match token { true => crate::auth::Grant::default(), false => crate::auth::Grant::all(), }); @@ -10709,10 +10709,10 @@ mod serve_tests { let session = SinkSession::gated_bi(bi.consume()).with_open_uni_gate(credit.consume()); let peer_setup = peer::PeerSetup::default(); peer_setup.set(peer::Peer::default()); - let auth = crate::auth::Handle::new(false); - let mut requests = auth.requests().unwrap(); + let auth = crate::auth::Link::new(false); + let mut requests = auth.handle().requests().unwrap(); // A limit of nothing, so only the token admits the subscription. - auth.authorize(&crate::auth::Grant::default()); + auth.handle().authorize(&crate::auth::Grant::default()); let publisher = Publisher::new( crate::time::sim(), session.clone(), @@ -11150,13 +11150,13 @@ mod serve_tests { /// Like [`serve`], but with an app auth acceptor wired in and the subscribe stream's /// reader scripted with `first_script` (a REQUEST_UPDATE, for the renewal tests). - fn serve_with_auth(version: Version, auth: crate::auth::Handle, first_script: Vec) -> Serve { + fn serve_with_auth(version: Version, auth: crate::auth::Link, first_script: Vec) -> Serve { serve_on(version, auth, ScriptedSession::per_stream(vec![first_script])) } /// [`serve_with_auth`] over a given session, such as one that finishes the stream after /// its script. - fn serve_on(version: Version, auth: crate::auth::Handle, session: ScriptedSession) -> Serve { + fn serve_on(version: Version, auth: crate::auth::Link, session: ScriptedSession) -> Serve { let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); let broadcast = origin.publish("room", crate::origin::Route::default()).unwrap(); let track = broadcast.create_track("video", crate::track::Info::timed()).unwrap(); @@ -11199,13 +11199,13 @@ 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::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(); + fn auth_covering_other() -> (crate::auth::Link, crate::auth::Requests, crate::auth::Auth) { + let auth = crate::auth::Link::new(true); + let requests = auth.handle().requests().unwrap(); let cred = auth.present(bytes::Bytes::from_static(b"cred"), true).unwrap(); // The peer lets us publish everything, and we let it read only "other". auth.granted(0, crate::auth::Grant::all()); - auth.authorize(&crate::auth::Grant { + auth.handle().authorize(&crate::auth::Grant { publish: crate::Patterns::new(), subscribe: crate::Pattern::subtree("other").unwrap().into(), expires: None, @@ -12292,9 +12292,9 @@ mod serve_tests { #[moq_clock::test] async fn a_request_token_on_a_no_auth_session_is_verified() { const VERSION: Version = Version::Draft18; - let auth = crate::auth::Handle::new(false); - let mut requests = auth.requests().unwrap(); - auth.authorize(&crate::auth::Grant::default()); + let auth = crate::auth::Link::new(false); + let mut requests = auth.handle().requests().unwrap(); + auth.handle().authorize(&crate::auth::Grant::default()); let h = serve_with_auth(VERSION, auth, Vec::new()); let mut group = h.track.create_group(group::Info { sequence: 0 }).unwrap(); @@ -12341,12 +12341,12 @@ mod serve_tests { async fn serve_token_subscribe( grant: crate::auth::Grant, limit: Option, - ) -> (crate::auth::Handle, Serve, Serving) { + ) -> (crate::auth::Link, Serve, Serving) { const VERSION: Version = Version::Draft18; - let auth = crate::auth::Handle::new(false); - let mut requests = auth.requests().unwrap(); + let auth = crate::auth::Link::new(false); + let mut requests = auth.handle().requests().unwrap(); if let Some(limit) = &limit { - auth.authorize(limit); + auth.handle().authorize(limit); } let h = serve_with_auth(VERSION, auth.clone(), Vec::new()); let mut group = h.track.create_group(group::Info { sequence: 0 }).unwrap(); @@ -12416,7 +12416,7 @@ mod serve_tests { still_serving(&mut serving).await, "a limit of nothing defers to the token" ); - auth.authorize(&crate::auth::Grant { + auth.handle().authorize(&crate::auth::Grant { publish: crate::Patterns::new(), subscribe: crate::Pattern::subtree("other").unwrap().into(), expires: None, @@ -12433,8 +12433,8 @@ mod serve_tests { #[moq_clock::test] async fn a_covering_peer_grant_never_skips_the_verifier() { const VERSION: Version = Version::Draft18; - let auth = crate::auth::Handle::new(true); - let mut requests = auth.requests().unwrap(); + let auth = crate::auth::Link::new(true); + let mut requests = auth.handle().requests().unwrap(); let _cred = auth.present(bytes::Bytes::from_static(b"cred"), true).unwrap(); auth.granted(0, crate::auth::Grant::all()); let h = serve_with_auth(VERSION, auth, Vec::new()); @@ -12483,7 +12483,7 @@ mod serve_tests { let (auth, _h, mut serving) = serve_token_subscribe(crate::auth::Grant::all(), Some(limit("room"))).await; assert!(still_serving(&mut serving).await, "the verified token admits it"); - auth.authorize(&limit("other")); + auth.handle().authorize(&limit("other")); assert!( still_serving(&mut serving).await, "narrowing the limit leaves the token-verified subscription" @@ -12495,8 +12495,8 @@ mod serve_tests { #[moq_clock::test] async fn a_token_on_a_subscription_admitted_without_one_is_refused() { for version in [Version::Draft16, Version::Draft22] { - let auth = crate::auth::Handle::new(false); - auth.authorize(&crate::auth::Grant::all()); + let auth = crate::auth::Link::new(false); + auth.handle().authorize(&crate::auth::Grant::all()); let h = serve_with_auth(version, auth, Vec::new()); let mut session = ScriptedSession::per_stream(vec![update_with_tokens(version, &[TOKEN])]); let mut stream = Stream::open(&mut session, version).await.unwrap(); @@ -12550,7 +12550,7 @@ mod serve_tests { a_token_without_a_verifier_is_refused(VERSION, limit).await; } - let unlimited = crate::auth::Handle::new(false); + let unlimited = crate::auth::Link::new(false); let h = serve_with_auth(VERSION, unlimited, Vec::new()); let mut group = h.track.create_group(group::Info { sequence: 0 }).unwrap(); group.write_frame(timestamp(), b"frame".as_slice()).unwrap(); @@ -12569,8 +12569,8 @@ mod serve_tests { /// Serve a token SUBSCRIBE on a session limited to `limit` with no verifier, and check it /// is refused NOT_SUPPORTED. async fn a_token_without_a_verifier_is_refused(version: Version, limit: crate::auth::Grant) { - let auth = crate::auth::Handle::new(false); - auth.authorize(&limit); + let auth = crate::auth::Link::new(false); + auth.handle().authorize(&limit); let h = serve_with_auth(version, auth, Vec::new()); let stream = Stream::open(&mut h.session.clone(), version).await.unwrap(); let mut serving = std::pin::pin!(h.publisher.clone().run_subscribe_stream(stream, token_subscribe())); @@ -12610,8 +12610,8 @@ mod serve_tests { const VERSION: Version = Version::Draft18; // Withdrawn before the application looked. - let auth = crate::auth::Handle::new(false); - let _requests = auth.requests().unwrap(); + let auth = crate::auth::Link::new(false); + let _requests = auth.handle().requests().unwrap(); let mut h = serve_with_auth(VERSION, auth, Vec::new()); h.publisher.subscriptions = crate::session::Slots::new(1); let stream = Stream::open(&mut h.session.clone(), VERSION).await.unwrap(); @@ -12632,8 +12632,8 @@ mod serve_tests { ); // Withdrawn after the application took it. - let auth = crate::auth::Handle::new(false); - let mut requests = auth.requests().unwrap(); + let auth = crate::auth::Link::new(false); + let mut requests = auth.handle().requests().unwrap(); let mut h = serve_with_auth(VERSION, auth, Vec::new()); h.publisher.subscriptions = crate::session::Slots::new(1); let stream = Stream::open(&mut h.session.clone(), VERSION).await.unwrap(); @@ -12672,8 +12672,8 @@ mod serve_tests { #[moq_clock::test] async fn a_request_token_cannot_exceed_the_peers_grant() { const VERSION: Version = Version::Draft18; - let auth = crate::auth::Handle::new(true); - let mut requests = auth.requests().unwrap(); + let auth = crate::auth::Link::new(true); + let mut requests = auth.handle().requests().unwrap(); let _cred = auth.present(bytes::Bytes::from_static(b"cred"), true).unwrap(); auth.granted( 0, @@ -12683,7 +12683,7 @@ mod serve_tests { expires: None, }, ); - auth.authorize(&crate::auth::Grant::default()); + auth.handle().authorize(&crate::auth::Grant::default()); let h = serve_with_auth(VERSION, auth, Vec::new()); let stream = Stream::open(&mut h.session.clone(), VERSION).await.unwrap(); let res = h @@ -14380,7 +14380,7 @@ mod tests { /// `origin` unsolicited, with one script per stream it opens. fn auth_publisher( origin: &crate::origin::Producer, - auth: &crate::auth::Handle, + auth: &crate::auth::Link, scripts: Vec>, version: Version, ) -> ( @@ -14438,7 +14438,7 @@ mod tests { async fn swaps_a_route_auth(version: Version) { let origin = unheld_origin(); - let auth = crate::auth::Handle::ietf(false); + let auth = crate::auth::Link::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) @@ -14508,7 +14508,7 @@ mod tests { const VERSION: Version = Version::Draft16; let origin = unheld_origin(); - let auth = crate::auth::Handle::ietf(false); + let auth = crate::auth::Link::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) @@ -14544,7 +14544,7 @@ mod tests { const VERSION: Version = Version::Draft16; let origin = unheld_origin(); - let auth = crate::auth::Handle::ietf(false); + let auth = crate::auth::Link::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) @@ -14591,7 +14591,7 @@ mod tests { const VERSION: Version = Version::Draft19; let origin = unheld_origin(); - let other = crate::auth::Handle::ietf(false); + let other = crate::auth::Link::ietf(false); let foreign = other .present(bytes::Bytes::from_static(b"foreign-secret"), false) .unwrap(); @@ -14601,7 +14601,7 @@ mod tests { settle().await; let ok = publish_namespace_ok(VERSION).await; - let (publisher, log) = auth_publisher(&origin, &crate::auth::Handle::ietf(false), vec![ok], VERSION); + let (publisher, log) = auth_publisher(&origin, &crate::auth::Link::ietf(false), vec![ok], VERSION); let mut run = std::pin::pin!(publisher.run_publish_namespaces()); poll_until(&mut run, &log, b"cam", 1).await; @@ -14620,7 +14620,7 @@ mod tests { const VERSION: Version = Version::Draft19; let origin = unheld_origin(); - let auth = crate::auth::Handle::ietf(false); + let auth = crate::auth::Link::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; @@ -14646,7 +14646,7 @@ mod tests { const VERSION: Version = Version::Draft14; let origin = unheld_origin(); - let auth = crate::auth::Handle::ietf(false); + let auth = crate::auth::Link::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) @@ -14680,7 +14680,7 @@ mod tests { const VERSION: Version = Version::Draft19; let origin = unheld_origin(); - let auth = crate::auth::Handle::ietf(false); + let auth = crate::auth::Link::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"))) @@ -14783,7 +14783,7 @@ mod tests { const VERSION: Version = Version::Draft19; let origin = unheld_origin(); - let auth = crate::auth::Handle::ietf(false); + let auth = crate::auth::Link::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"))) @@ -14838,7 +14838,7 @@ mod tests { async fn advertises_again_unanswered(version: Version) { let origin = unheld_origin(); - let auth = crate::auth::Handle::ietf(false); + let auth = crate::auth::Link::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"))) diff --git a/rs/moq-net/src/ietf/session.rs b/rs/moq-net/src/ietf/session.rs index 8c7b01bd03e..17b732f07f7 100644 --- a/rs/moq-net/src/ietf/session.rs +++ b/rs/moq-net/src/ietf/session.rs @@ -76,7 +76,7 @@ pub struct Config { /// peer's token requests during its handshake. Supports AUTH exactly when the /// version can negotiate it and this side offers it; the peer's SETUP decides whether /// it does. - pub auth: crate::auth::Handle, + pub auth: crate::auth::Link, /// Uni streams that arrived before that pre-read SETUP (see [`PeerSetup::early`]), /// classified by the session before it accepts any more. @@ -1475,7 +1475,7 @@ async fn read_goaways( /// Closes the auth handle when the session driver ends without finishing, releasing /// anything still waiting on a token. -struct AuthClose(crate::auth::Handle); +struct AuthClose(crate::auth::Link); impl Drop for AuthClose { fn drop(&mut self) { @@ -1487,7 +1487,7 @@ impl Drop for AuthClose { /// instead of leaving it to wait for a subscription that never comes. See /// [`crate::auth::Enforce`]. async fn enforce_grant( - auth: crate::auth::Handle, + auth: crate::auth::Link, origin: origin::Consumer, mut session: S, ) -> Result<(), Error> { @@ -1583,7 +1583,7 @@ mod tests { auth: true, ..Default::default() }), - auth: crate::auth::Handle::new(true), + auth: crate::auth::Link::new(true), early_unis: Vec::new(), extensions: Default::default(), }) @@ -1638,7 +1638,7 @@ mod tests { auth: true, ..Default::default() }), - auth: crate::auth::Handle::new(true), + auth: crate::auth::Link::new(true), early_unis: Vec::new(), extensions: Default::default(), }) @@ -1684,7 +1684,7 @@ mod tests { auth: true, ..Default::default() }), - auth: crate::auth::Handle::new(true), + auth: crate::auth::Link::new(true), early_unis: Vec::new(), extensions: Default::default(), }) @@ -1783,7 +1783,7 @@ mod tests { }, ..Default::default() }), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), early_unis: Vec::new(), extensions: Default::default(), }) @@ -1839,7 +1839,7 @@ mod tests { peer_setup_stream: None, // The requests wait on the peer's SETUP (MoQ Hidden). peer_declared: Some(peer::Peer::default()), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), early_unis: Vec::new(), extensions: Default::default(), }) @@ -1914,7 +1914,7 @@ mod tests { solicit, ..Default::default() }), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), early_unis: Vec::new(), extensions: Default::default(), }) @@ -1999,7 +1999,7 @@ mod tests { authority: None, peer_setup_stream: None, peer_declared, - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), early_unis: Vec::new(), extensions: Default::default(), }) @@ -2068,7 +2068,7 @@ mod tests { /// A publishing draft-18 session with AUTH available locally, against a peer that /// declared `auth`. Everything the session needs to keep running is held here. struct AuthSession { - handle: crate::auth::Handle, + handle: crate::auth::Link, log: crate::lite::test_transport::Log, _origin: crate::origin::Producer, _cam: crate::AnnounceProducer, @@ -2085,7 +2085,7 @@ mod tests { let session = crate::lite::test_transport::SinkSession::gated_bi(gate.consume()); let log = session.log.clone(); - let handle = crate::auth::Handle::ietf(true); + let handle = crate::auth::Link::ietf(true); let (driver, goaway) = start(Config { runtime: crate::time::sim(), session, @@ -2129,12 +2129,12 @@ mod tests { let session = auth_session(false); let (handle, log) = (&session.handle, &session.log); - let auth = moq_clock::timeout(std::time::Duration::from_secs(1), handle.add("token")) + let auth = moq_clock::timeout(std::time::Duration::from_secs(1), handle.handle().add("token")) .await .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"); + assert_eq!(handle.handle().grant().peek(), None, "no grant without the extension"); for _ in 0..ANNOUNCE_TURNS { if occurrences(log, b"solo-cam") > 0 { @@ -2165,7 +2165,7 @@ mod tests { } /// What the draft-18 SETUP that `start` writes says about MoQ Auth and MoQ Solicit. - async fn our_setup(client: bool, auth: crate::auth::Handle, extensions: crate::setup::Extensions) -> (bool, bool) { + async fn our_setup(client: bool, auth: crate::auth::Link, extensions: crate::setup::Extensions) -> (bool, bool) { const VERSION: Version = Version::Draft18; let session = crate::lite::test_transport::ScriptedSession::new(Vec::new()); @@ -2216,13 +2216,13 @@ mod tests { no_solicit.solicit = false; // A client's handle supports AUTH exactly when it offers it. - assert_eq!(our_setup(true, crate::auth::Handle::new(true), all).await, (true, true)); + assert_eq!(our_setup(true, crate::auth::Link::new(true), all).await, (true, true)); assert_eq!( - our_setup(true, crate::auth::Handle::new(false), no_auth).await, + our_setup(true, crate::auth::Link::new(false), no_auth).await, (false, true) ); assert_eq!( - our_setup(true, crate::auth::Handle::new(true), no_solicit).await, + our_setup(true, crate::auth::Link::new(true), no_solicit).await, (true, false) ); } @@ -2233,11 +2233,11 @@ mod tests { async fn a_server_offers_auth_only_to_a_client_that_did() { let all = crate::setup::Extensions::default(); assert_eq!( - our_setup(false, crate::auth::Handle::new(true), all).await, + our_setup(false, crate::auth::Link::new(true), all).await, (true, true) ); assert_eq!( - our_setup(false, crate::auth::Handle::new(false), all).await, + our_setup(false, crate::auth::Link::new(false), all).await, (false, true) ); } @@ -2286,7 +2286,7 @@ mod tests { ..Default::default() }), // As `Server` builds it: offered by the client, so supported iff we offer it. - auth: crate::auth::Handle::new(offered), + auth: crate::auth::Link::new(offered), early_unis: Vec::new(), extensions: crate::setup::Extensions::default(), }) @@ -2373,7 +2373,7 @@ mod tests { // Pre-settled, so nothing waits on a SETUP the dead stream will never // carry and the dispatch loop actually runs. peer_declared: Some(peer::Peer::default()), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), early_unis: Vec::new(), extensions: Default::default(), }) @@ -2414,7 +2414,7 @@ mod tests { authority: Some(String::from_utf8(AUTHORITY.to_vec()).unwrap()), peer_setup_stream: None, peer_declared: Some(peer::Peer::default()), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), early_unis: Vec::new(), extensions: Default::default(), }) @@ -2681,7 +2681,7 @@ mod tests { authority: None, peer_setup_stream: None, peer_declared: Some(peer::Peer::default()), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), early_unis: Vec::new(), extensions: Default::default(), }) @@ -2824,7 +2824,7 @@ mod tests { authority: None, peer_setup_stream: None, peer_declared: Some(peer::Peer::default()), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), early_unis: Vec::new(), extensions: Default::default(), }) @@ -2891,7 +2891,7 @@ mod tests { authority: None, peer_setup_stream: None, peer_declared: Some(peer::Peer::default()), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), early_unis: Vec::new(), extensions: Default::default(), limits: Default::default(), @@ -2994,7 +2994,7 @@ mod tests { authority: None, peer_setup_stream: None, peer_declared: None, - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), early_unis: Vec::new(), extensions: Default::default(), }) @@ -3116,7 +3116,7 @@ mod tests { authority: None, peer_setup_stream: None, peer_declared: Some(peer::Peer::default()), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), early_unis: Vec::new(), extensions: Default::default(), }) @@ -3347,7 +3347,7 @@ mod tests { authority: None, peer_setup_stream: None, peer_declared: None, - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), early_unis: Vec::new(), extensions: Default::default(), }) @@ -3449,7 +3449,7 @@ mod tests { authority: None, peer_setup_stream: None, peer_declared: Some(peer::Peer::default()), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), early_unis: Vec::new(), extensions: Default::default(), }) diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index d590a411019..5e3d79bea57 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -753,7 +753,7 @@ pub(super) struct Subscriber { // fail requests that land before the replacement is up. going_away: crate::goaway::GoingAway, // Our grant (MoQ Auth): a subscription it stops covering is cancelled. - auth: crate::auth::Handle, + auth: crate::auth::Link, // What this session may allocate up front for objects still arriving. frames: frame::Budget, // Namespaces the peer may have announced at once (`session::Limits::announces`). @@ -847,7 +847,7 @@ where tasks, version, going_away, - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), frames: Default::default(), announces: Default::default(), validity: Default::default(), @@ -857,7 +857,7 @@ where /// Bound what we subscribe to by the grant this session's tokens earn (MoQ Auth), /// and what the peer may publish to us by the session's limit, as either changes. - pub fn with_auth(mut self, auth: crate::auth::Handle) -> Self { + pub fn with_auth(mut self, auth: crate::auth::Link) -> Self { self.auth = auth; let this = self.clone(); self.tasks.push(async move { this.run_limit().await }); @@ -6801,13 +6801,13 @@ mod tests { ) -> ( Subscriber, crate::lite::test_transport::Log, - crate::auth::Handle, + crate::auth::Link, Vec, crate::util::TaskSet, ) { let session = crate::lite::test_transport::ScriptedSession::new(Vec::new()); let log = session.log.clone(); - let auth = crate::auth::Handle::new(true); + let auth = crate::auth::Link::new(true); let setup = auth.present(bytes::Bytes::new(), true).unwrap(); auth.granted(setup.id(), crate::auth::Grant::default()); let unanswered = auth.present(bytes::Bytes::from_static(b"new"), false).unwrap(); @@ -9287,8 +9287,8 @@ mod tests { // Admitted by its own token, which holds the one slot for the request's life. let path = crate::Path::new("room/host").to_owned(); let hold: crate::auth::Hold = std::sync::Arc::new(subscriber.announces.acquire().unwrap()); - let auth = crate::auth::Handle::new(true); - let mut requests = auth.requests().unwrap(); + let auth = crate::auth::Link::new(true); + let mut requests = auth.handle().requests().unwrap(); let token = crate::setup::Token { kind: crate::setup::Token::OUT_OF_BAND, value: vec![0x00, 0xff], @@ -9746,8 +9746,8 @@ mod tests { let peer_setup = peer::PeerSetup::default(); peer_setup.set(peer::Peer::default()); - let auth = crate::auth::Handle::new(true); - let requests = auth.requests().unwrap(); + let auth = crate::auth::Link::new(true); + let requests = auth.handle().requests().unwrap(); let cred = auth.present(bytes::Bytes::from_static(b"cred"), true).unwrap(); auth.granted( 0, @@ -9758,7 +9758,7 @@ mod tests { }, ); // We let the peer announce only "other". - auth.authorize(&crate::auth::Grant { + auth.handle().authorize(&crate::auth::Grant { publish: crate::Pattern::subtree("other").unwrap().into(), subscribe: crate::Patterns::new(), expires: None, @@ -10062,7 +10062,7 @@ mod tests { #[moq_clock::test] async fn a_covering_peer_grant_never_skips_the_verifier() { const VERSION: Version = Version::Draft18; - let auth = crate::auth::Handle::new(true); + let auth = crate::auth::Link::new(true); let _cred = auth.present(bytes::Bytes::from_static(b"cred"), true).unwrap(); auth.granted(0, crate::auth::Grant::all()); let (mut subscriber, mut requests, consumer, session) = announce_harness_with(VERSION, auth); @@ -10115,8 +10115,8 @@ mod tests { subscribe: crate::Patterns::new(), expires: None, }; - let auth = crate::auth::Handle::new(false); - auth.authorize(&limit("room")); + let auth = crate::auth::Link::new(false); + auth.handle().authorize(&limit("room")); let (mut subscriber, mut requests, consumer, session) = announce_harness_with(VERSION, auth.clone()); let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); let consulted = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)); @@ -10153,7 +10153,7 @@ mod tests { "the verifier ran though the limit covers the announce" ); - auth.authorize(&limit("other")); + auth.handle().authorize(&limit("other")); for _ in 0..50 { let _ = futures::poll!(acceptor.as_mut()); assert!(futures::poll!(run.as_mut()).is_pending(), "the announce ended"); @@ -10170,8 +10170,8 @@ mod tests { #[moq_clock::test] async fn a_token_authorized_announce_is_exempt_from_the_limit() { const VERSION: Version = Version::Draft18; - let auth = crate::auth::Handle::new(false); - auth.authorize(&crate::auth::Grant::default()); + let auth = crate::auth::Link::new(false); + auth.handle().authorize(&crate::auth::Grant::default()); let (mut subscriber, mut requests, consumer, session) = announce_harness_with(VERSION, auth.clone()); let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); let acceptor = async move { @@ -10198,7 +10198,7 @@ mod tests { } assert!(routed, "the token admits the announce outside the limit"); - auth.authorize(&crate::auth::Grant { + auth.handle().authorize(&crate::auth::Grant { publish: crate::Pattern::subtree("other").unwrap().into(), subscribe: crate::Patterns::new(), expires: None, @@ -10219,8 +10219,8 @@ mod tests { #[moq_clock::test] async fn a_token_authorized_announce_admits_its_subscriptions() { const VERSION: Version = Version::Draft18; - let auth = crate::auth::Handle::new(false); - auth.authorize(&crate::auth::Grant::default()); + let auth = crate::auth::Link::new(false); + auth.handle().authorize(&crate::auth::Grant::default()); // The announce's peer says nothing; the subscription's accepts it. let subscribe_ok = { let log = crate::lite::test_transport::Log::default(); @@ -11023,9 +11023,9 @@ mod tests { origin::Consumer, crate::lite::test_transport::ScriptedSession, ) { - let auth = crate::auth::Handle::new(false); + let auth = crate::auth::Link::new(false); if let Some(limit) = &limit { - auth.authorize(limit); + auth.handle().authorize(limit); } announce_harness_with(version, auth) } @@ -11033,7 +11033,7 @@ mod tests { /// An announce harness over `auth`, set up by the caller, taking its requests. fn announce_harness_with( version: Version, - auth: crate::auth::Handle, + auth: crate::auth::Link, ) -> ( Subscriber, crate::auth::Requests, @@ -11047,7 +11047,7 @@ mod tests { /// An announce harness over `auth` and `session`, both set up by the caller. fn announce_harness_on( version: Version, - auth: crate::auth::Handle, + auth: crate::auth::Link, session: crate::lite::test_transport::ScriptedSession, ) -> ( Subscriber, @@ -11062,7 +11062,7 @@ mod tests { let peer_setup = peer::PeerSetup::default(); peer_setup.set(peer::Peer::default()); - let requests = auth.requests().unwrap(); + let requests = auth.handle().requests().unwrap(); let subscriber = Subscriber::new( crate::time::sim(), @@ -14956,7 +14956,7 @@ mod joining_fetch_tests { .iter() .any(|(id, _)| *id == ietf::Fetch::ID) ); - run.subscriber.auth.authorize(&crate::auth::Grant::default()); + run.subscriber.auth.handle().authorize(&crate::auth::Grant::default()); settle().await; let mut received = (&mut run._hold.2) .now_or_never() diff --git a/rs/moq-net/src/lite/publisher.rs b/rs/moq-net/src/lite/publisher.rs index 2850cf0f487..5c8a1f91ff3 100644 --- a/rs/moq-net/src/lite/publisher.rs +++ b/rs/moq-net/src/lite/publisher.rs @@ -43,7 +43,7 @@ pub(super) struct PublisherConfig { pub peer_hop: Option, /// Our tokens' grants, which bound what we announce and serve, and the app's /// claim on answering the peer's tokens. - pub auth: crate::auth::Handle, + pub auth: crate::auth::Link, /// What the default acceptor grants the peer's connection credential. pub peer_grant: crate::auth::Grant, /// The supervisor's estimate, which a PROBE stream follows. @@ -77,7 +77,7 @@ struct Shared { priority: PriorityQueue, version: Version, goaway: crate::goaway::Protocol, - auth: crate::auth::Handle, + auth: crate::auth::Link, peer_grant: crate::auth::Grant, // Control streams still serving the peer data, which a draining close waits for. owed: AtomicUsize, @@ -269,7 +269,7 @@ impl Publisher { announced: &mut announce::Consumer, self_origin: Hop, version: Version, - auth: Option, + auth: Option, ) -> Result<(), Error> { let mut run = AnnounceRun::new(crate::PathOwned::default(), self_origin, version); run.auth = auth.map(|handle| { @@ -2674,8 +2674,8 @@ mod announce_test { let mut run = AnnounceRun::new(crate::Path::new("").to_owned(), consumer.hop(), Version::Lite07); let waiter = kio::Waiter::noop(); assert!(run.poll(&mut stream, &consumer, &mut announced, &waiter).is_pending()); - let auth = crate::auth::Handle::new(false); - auth.authorize(&crate::auth::Grant { + let auth = crate::auth::Link::new(false); + auth.handle().authorize(&crate::auth::Grant { subscribe: crate::Pattern::literal("b").unwrap().into(), ..Default::default() }); @@ -2713,8 +2713,8 @@ mod announce_test { let mut run = AnnounceRun::new(crate::Path::new("").to_owned(), consumer.hop(), Version::Lite07); let waiter = kio::Waiter::noop(); assert!(run.poll(&mut stream, &consumer, &mut announced, &waiter).is_pending()); - let auth = crate::auth::Handle::new(false); - auth.authorize(&crate::auth::Grant::default()); + let auth = crate::auth::Link::new(false); + auth.handle().authorize(&crate::auth::Grant::default()); let Poll::Ready(restricted) = auth.poll_permit(crate::auth::Direction::Publish, &mut 0, &waiter) else { panic!("the changed grant is ready"); }; @@ -2928,7 +2928,7 @@ mod announce_test { } /// [`harness_on`], announcing under `auth`'s grant. - async fn harness_with(version: Version, auth: Option) -> Harness { + async fn harness_with(version: Version, auth: Option) -> Harness { let origin = Hop::new(1).unwrap().produce(); let announcement = origin .announce( @@ -3132,8 +3132,8 @@ mod announce_test { expires: None, }; for anonymous in [false, true] { - let auth = crate::auth::Handle::new(false); - auth.authorize(&grant(&["cam"])); + let auth = crate::auth::Link::new(false); + auth.handle().authorize(&grant(&["cam"])); let mut h = harness_with(Version::Lite07, Some(auth.clone())).await; // Same chain and cost, so only the instance changed. Both land before the loop polls. @@ -3144,7 +3144,7 @@ mod announce_test { None => h.origin.clone().peer().announce("cam", route), } .unwrap(); - auth.authorize(&grant(&["cam", "other"])); + auth.handle().authorize(&grant(&["cam", "other"])); moq_clock::sleep(crate::origin::DEFAULT_UPDATE_HOLD).await; settle().await; match h.wire.take_announces().as_slice() { @@ -6509,7 +6509,7 @@ mod tests { peer_setup, goaway, peer_hop: Some(assigned), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), peer_grant: Default::default(), client: false, subscriptions: Default::default(), @@ -6680,7 +6680,7 @@ mod tests { peer_setup: crate::lite::PeerSetup::new(false, crate::transport::Binding::InBand), goaway, peer_hop: None, - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), peer_grant: Default::default(), client: false, subscriptions: Default::default(), @@ -6979,7 +6979,7 @@ mod tests { peer_setup, goaway, peer_hop: None, - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), peer_grant: Default::default(), client: false, subscriptions: Default::default(), @@ -7114,7 +7114,7 @@ mod tests { peer_hop: None, subscriptions: Default::default(), fetches: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), peer_grant: Default::default(), client: false, }); @@ -7201,7 +7201,7 @@ mod tests { peer_setup, goaway, peer_hop: None, - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), peer_grant: Default::default(), client: false, subscriptions: Default::default(), @@ -7322,7 +7322,7 @@ mod tests { peer_hop: None, subscriptions: Default::default(), fetches: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), peer_grant: Default::default(), client: false, }); @@ -7483,7 +7483,7 @@ mod tests { peer_hop: None, subscriptions: crate::session::Slots::new(0), fetches: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), peer_grant: Default::default(), client: false, }); @@ -7522,7 +7522,7 @@ mod tests { peer_setup, goaway, peer_hop: None, - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), peer_grant: Default::default(), client: false, subscriptions: Default::default(), @@ -7589,7 +7589,7 @@ mod tests { peer_setup, goaway, peer_hop: None, - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), peer_grant: Default::default(), client: false, subscriptions: crate::session::Slots::new(0), diff --git a/rs/moq-net/src/lite/session.rs b/rs/moq-net/src/lite/session.rs index 3e671e20999..c0af2829995 100644 --- a/rs/moq-net/src/lite/session.rs +++ b/rs/moq-net/src/lite/session.rs @@ -21,7 +21,7 @@ pub(crate) struct SessionStart { /// The session-side GOAWAY halves, stored on the public [`crate::Session`]. pub goaway: crate::goaway::Handle, /// The session's AUTH tokens and grants, stored on the public [`crate::Session`]. - pub auth: crate::auth::Handle, + pub auth: crate::auth::Link, } /// Server: read the peer's single SETUP message off its Setup Stream before starting @@ -186,7 +186,7 @@ pub struct Config { /// The session's auth handle, created before [`start`] so a server can take the /// peer's token requests during its handshake. Supports AUTH exactly when `version` /// does. - pub auth: crate::auth::Handle, + pub auth: crate::auth::Link, /// What the peer may make this session hold. pub limits: crate::session::Limits, } @@ -472,7 +472,7 @@ struct Present { runtime: crate::time::Deadlines, session: S, version: Version, - handle: crate::auth::Handle, + handle: crate::auth::Link, going_away: crate::goaway::GoingAway, tokens: kio::Tasks>, /// Set by a token whose peer broke the protocol, which ends the session. @@ -519,7 +519,7 @@ struct PresentToken { runtime: crate::time::Deadlines, session: S, version: Version, - handle: crate::auth::Handle, + handle: crate::auth::Link, going_away: crate::goaway::GoingAway, violation: kio::Shared>, id: u64, @@ -529,6 +529,8 @@ struct PresentToken { enum PresentState { Open { token: bytes::Bytes }, Run { stream: Stream, answered: bool }, + /// Refused for a reason that may pass: present it again once the deadline fires. + Retry { deadline: crate::time::Deadline }, Done, } @@ -641,10 +643,29 @@ impl PresentToken { self.handle.refused(self.id); } *answered = true; - return Poll::Ready(err); + let Some(delay) = self.handle.retry(self.id, &err) else { + return Poll::Ready(err); + }; + tracing::debug!(?delay, "auth token refused transiently; presenting it again"); + let retry = PresentState::Retry { + deadline: crate::time::Deadline::after(&self.runtime, delay), + }; + if let PresentState::Run { stream, .. } = std::mem::replace(&mut self.state, retry) { + stream.writer.abort(&Error::Cancel); + } } } } + PresentState::Retry { deadline } => { + if self.handle.poll_withdrawn(self.id, waiter).is_ready() { + return Poll::Ready(Error::Cancel); + } + ready!(deadline.poll(waiter)); + let Some(token) = self.handle.reopen(self.id) else { + return Poll::Ready(Error::Cancel); + }; + self.state = PresentState::Open { token }; + } PresentState::Done => return Poll::Ready(Error::Cancel), } } @@ -922,7 +943,7 @@ mod tests { version: Version::Lite07, our_setup: Setup::default(), peer_setup: None, - auth: crate::auth::Handle::new(true), + auth: crate::auth::Link::new(true), }) .unwrap(); let mut driver = start.driver; @@ -954,7 +975,7 @@ mod tests { version: Version::Lite07, our_setup: Setup::default(), peer_setup: None, - auth: crate::auth::Handle::new(true), + auth: crate::auth::Link::new(true), }) .unwrap(); let mut driver = start.driver; @@ -1040,7 +1061,7 @@ mod tests { version: Version::Lite07, our_setup: Setup::default(), peer_setup: None, - auth: crate::auth::Handle::new(true), + auth: crate::auth::Link::new(true), }) .unwrap(); let mut driver = start.driver; @@ -1108,7 +1129,7 @@ mod tests { our_setup: Setup::default(), peer_setup: None, client: false, - auth: crate::auth::Handle::new(version.has_auth()), + auth: crate::auth::Link::new(version.has_auth()), limits: Default::default(), }) .unwrap(); @@ -1162,7 +1183,7 @@ mod tests { our_setup: Setup::default(), peer_setup: None, client: false, - auth: crate::auth::Handle::new(version.has_auth()), + auth: crate::auth::Link::new(version.has_auth()), limits: Default::default(), }) .unwrap(); @@ -1194,7 +1215,7 @@ mod tests { version, our_setup: Setup::default(), peer_setup, - auth: crate::auth::Handle::new(version.has_auth()), + auth: crate::auth::Link::new(version.has_auth()), limits: Default::default(), }) .unwrap() diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index 825dcbe86ec..841a33cbe77 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -42,7 +42,7 @@ pub(super) struct SubscriberConfig { /// [`crate::origin::Cost::DRAIN`], so a replacement session outranks it. pub going_away: crate::goaway::GoingAway, /// Our tokens' grants: a subscription the union stops covering is cancelled. - pub auth: crate::auth::Handle, + pub auth: crate::auth::Link, } #[derive(Clone)] @@ -83,7 +83,7 @@ pub(super) struct Subscriber { /// [`SourceServe`] machines. sources: kio::Queue, going_away: crate::goaway::GoingAway, - auth: crate::auth::Handle, + auth: crate::auth::Link, /// What this session may allocate up front for frames still arriving. frames: frame::Budget, /// Broadcasts the peer may have announced at once (`session::Limits::announces`). @@ -2434,7 +2434,7 @@ mod tests { peer_hop: None, cost: None, going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); let track = track::Producer::new( std::sync::Arc::new(crate::broadcast::Info::default()), @@ -2489,7 +2489,7 @@ mod tests { peer_hop: None, cost: None, going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); let mut track = track::Producer::new( @@ -2564,7 +2564,7 @@ mod tests { peer_hop: None, cost: None, going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); let pool = crate::cache::Pool::unbounded(); let track = track::Producer::new( @@ -2723,7 +2723,7 @@ mod tests { peer_hop: None, cost: None, going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); let track = track::Producer::new( @@ -2792,7 +2792,7 @@ mod tests { peer_hop: None, cost: None, going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); let track = track::Producer::new( @@ -2857,7 +2857,7 @@ mod tests { peer_hop: None, cost: None, going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); let serve = TrackServe { validity: Default::default(), @@ -2929,7 +2929,7 @@ mod tests { peer_hop: None, cost: None, going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); let serve = TrackServe { validity: Default::default(), @@ -2976,7 +2976,7 @@ mod tests { peer_hop: None, cost: None, going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); let serve = TrackServe { validity: Default::default(), @@ -3075,7 +3075,7 @@ mod tests { peer_hop: None, cost: None, going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); let broadcast = crate::broadcast::Info::new().produce(); @@ -3142,7 +3142,7 @@ mod tests { peer_hop: None, cost: None, going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); let broadcast = crate::broadcast::Info::new().produce(); @@ -3202,7 +3202,7 @@ mod tests { peer_hop: None, cost: None, going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); let broadcast = crate::broadcast::Info::new().produce(); @@ -3244,7 +3244,7 @@ mod tests { /// holding its task and track until a token that may never be answered. #[moq_clock::test] async fn an_unused_track_stops_waiting_for_auth() { - let auth = crate::auth::Handle::new(true); + let auth = crate::auth::Link::new(true); let _token = auth.present(bytes::Bytes::from_static(b"new"), false).unwrap(); let subscriber = Subscriber::new(SubscriberConfig { runtime: crate::time::sim(), @@ -3315,7 +3315,7 @@ mod tests { peer_hop: None, cost: None, going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); let subscribes = subscriber.subscribes.clone(); let serve = TrackServe { @@ -3370,7 +3370,7 @@ mod tests { // Writes park, so the SUBSCRIBE is on an open stream but not yet flushed. let gate = kio::Producer::new(false); let session = SinkSession::gated_bi(gate.consume()); - let auth = crate::auth::Handle::new(false); + let auth = crate::auth::Link::new(false); let origin = origin::Config::new(crate::Hop::new(1).unwrap()).produce(); let subscriber = Subscriber::new(SubscriberConfig { @@ -3410,7 +3410,7 @@ mod tests { assert!(kio::Task::poll(&mut running, &kio::Waiter::noop()).is_pending()); assert_eq!(session.log.bi_opens(), 1, "the SUBSCRIBE stream is open"); - auth.authorize(&crate::auth::Grant::default()); + auth.handle().authorize(&crate::auth::Grant::default()); assert!(kio::Task::poll(&mut running, &kio::Waiter::noop()).is_ready()); let unauthorized = crate::StreamError::Unauthorized.to_code(); @@ -3446,7 +3446,7 @@ mod tests { peer_hop: None, cost: None, going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); let broadcast = crate::broadcast::Info::new().produce(); let producer = broadcast @@ -3515,7 +3515,7 @@ mod tests { peer_hop: None, cost: None, going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); let serve = TrackServe { validity: Default::default(), @@ -3565,7 +3565,7 @@ mod tests { peer_hop: None, cost: None, going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); let serve = TrackServe { validity: Default::default(), @@ -4076,7 +4076,7 @@ mod tests { cost: None, peer_hop: Some(assigned), going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); let path = Path::new("room/host").to_owned(); @@ -4131,7 +4131,7 @@ mod tests { cost: None, peer_hop: Some(crate::Hop::new(777).unwrap()), going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); let mut announced = Announced::default(); @@ -4185,7 +4185,7 @@ mod tests { cost: None, peer_hop: Some(crate::Hop::new(777).unwrap()), going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); let path = Path::new("pool").to_owned(); let mut announced = Announced::default(); @@ -4254,7 +4254,7 @@ mod tests { cost: None, peer_hop: Some(crate::Hop::new(777).unwrap()), going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); let mut announced = Announced::default(); subscriber @@ -4310,7 +4310,7 @@ mod tests { cost: None, peer_hop: Some(assigned), going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); let path = Path::new("room/host").to_owned(); @@ -4369,7 +4369,7 @@ mod tests { cost: None, peer_hop: Some(assigned), going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); let path = Path::new("room/host").to_owned(); @@ -4428,7 +4428,7 @@ mod tests { cost: None, peer_hop: Some(assigned), going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); // The sender's identity is already in the chain: the route came back through it. @@ -4495,7 +4495,7 @@ mod tests { cost: None, peer_hop: Some(assigned), going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); // The path as advertised to the peer: our hop is already in the chain. @@ -4555,7 +4555,7 @@ mod tests { peer_hop: Some(assigned), cost: None, going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); // An announce with an empty chain and no responder id: the versions that @@ -4602,7 +4602,7 @@ mod tests { cost: None, peer_hop: Some(assigned), going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); let hops = crate::Hops::try_from(vec![crate::Hop::UNKNOWN, crate::Hop::UNKNOWN]).unwrap(); @@ -4684,7 +4684,7 @@ mod tests { cost: None, peer_hop: None, going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); (subscriber, consumer) } @@ -4752,7 +4752,7 @@ mod tests { let mut cursor = consumer.announced(); cursor.expect_next_active("room/host").await; - auth.authorize(&crate::auth::Grant { + auth.handle().authorize(&crate::auth::Grant { publish: Default::default(), subscribe: [crate::Pattern::subtree("other").unwrap()].into_iter().collect(), expires: None, @@ -4793,7 +4793,7 @@ mod tests { cost: None, peer_hop: None, going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); let path = Path::new("room/host").to_owned(); @@ -4877,7 +4877,7 @@ mod tests { cost: Some(1), peer_hop: None, going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); let mut prefix = AnnouncePrefix::new(subscriber, Path::new("").to_owned()); let mut cursor = consumer.announced(); @@ -4944,7 +4944,7 @@ mod tests { cost: Some(1), peer_hop: None, going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); (AnnouncePrefix::new(subscriber, Path::new("").to_owned()), consumer) } @@ -5115,7 +5115,7 @@ mod tests { cost: Some(1), peer_hop: None, going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); let slots = crate::session::Slots::new(2); subscriber.announces = slots.clone(); diff --git a/rs/moq-net/src/model/origin.rs b/rs/moq-net/src/model/origin.rs index 607e23345ab..0f6e6e64166 100644 --- a/rs/moq-net/src/model/origin.rs +++ b/rs/moq-net/src/model/origin.rs @@ -467,14 +467,14 @@ pub struct Route { /// The credential the request advertising this route carries, from /// [`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 + /// Only a session built on the handle that issued it carries it, as the 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 (which rides the session's scope) /// advertise the route unnamed. Swapping it is an update that renews the request with - /// REQUEST_UPDATE from draft 16, 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. + /// REQUEST_UPDATE from draft 16, or ends and makes it again before. A handle passed to + /// `Client::with_auth` presents it on every session it backs, so the route keeps it + /// across a reconnect. Local, like [`Self::source`]: a relay never forwards it. pub auth: Option, /// The announcing session's declared or assigned identity. @@ -7283,7 +7283,7 @@ mod tests { #[moq_clock::test] #[cfg(feature = "async")] async fn an_auth_swap_is_an_update() { - let handle = crate::auth::Handle::ietf(false); + let handle = crate::auth::Link::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(); diff --git a/rs/moq-net/src/server.rs b/rs/moq-net/src/server.rs index 27f5c9fc705..fd8449193e5 100644 --- a/rs/moq-net/src/server.rs +++ b/rs/moq-net/src/server.rs @@ -150,7 +150,7 @@ impl Server { version: lite::Version, client_setup: Option>, peer_hop: Option, - auth: crate::auth::Handle, + auth: crate::auth::Link, ) -> Result<(Session, crate::Driver), Error> where S: crate::transport::poll::Session, @@ -294,7 +294,7 @@ impl Server { _ => return Err(Error::Version), }; - let auth = crate::auth::Handle::new(match &handshake { + let auth = crate::auth::Link::new(match &handshake { PausedHandshake::LiteBare { version, .. } | PausedHandshake::LiteSetup { version, .. } => { version.has_auth() } @@ -462,8 +462,8 @@ impl Server { // 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), + Version::Ietf(_) => crate::auth::Link::ietf(false), + Version::Lite(_) => crate::auth::Link::new(false), }, inner: Some(RequestInner { server: self.clone(), @@ -503,7 +503,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::ietf(peer_setup.declared.auth && self.extensions.auth), + auth: crate::auth::Link::ietf(peer_setup.declared.auth && self.extensions.auth), inner: Some(RequestInner { server: self.clone(), runtime, @@ -535,7 +535,7 @@ pub struct Handshake { assigned_hop: crate::Hop, /// The session's auth handle, available before [`Handshake::ok`] so the caller can /// take the peer's token requests before any arrive. - auth: crate::auth::Handle, + auth: crate::auth::Link, // Taken by `ok`/`close`; `Drop` rejects the handshake if neither ran. inner: Option>, } @@ -584,7 +584,7 @@ trait Paused: MaybeSend + MaybeSync { server: Server, runtime: Deadlines, peer_hop: Option, - auth: crate::auth::Handle, + auth: crate::auth::Link, ) -> Accept; /// Reject the handshake, closing the transport with `err`'s wire code. @@ -611,7 +611,7 @@ where server: Server, runtime: Deadlines, peer_hop: Option, - auth: crate::auth::Handle, + auth: crate::auth::Link, ) -> Accept { use crate::util::MaybeBoxedExt as _; async move { @@ -691,7 +691,7 @@ where server: Server, runtime: Deadlines, peer_hop: Option, - auth: crate::auth::Handle, + auth: crate::auth::Link, ) -> Accept { use crate::util::MaybeBoxedExt as _; async move { @@ -849,7 +849,7 @@ where /// Take [`requests`](crate::auth::Handle::requests) here to answer the client's /// tokens yourself; the choice is fixed once the session's driver first runs. pub fn auth(&self) -> crate::auth::Handle { - self.auth.clone() + self.auth.handle().clone() } /// The credential the client presented in its SETUP's `AUTHORIZATION TOKEN` option. @@ -1512,7 +1512,7 @@ mod tests { origin: None, token: None, assigned_hop: Hop::random(), - auth: crate::auth::Handle::ietf(false), + auth: crate::auth::Link::ietf(false), inner: Some(RequestInner { server: Server::new().with_publisher(&origin), runtime: Deadlines::new(moq_clock::now()), diff --git a/rs/moq-net/src/session.rs b/rs/moq-net/src/session.rs index 09a0d80bac5..5a464269406 100644 --- a/rs/moq-net/src/session.rs +++ b/rs/moq-net/src/session.rs @@ -271,6 +271,9 @@ impl Session { /// The tokens this side presented and the grant they earned, plus the tokens /// the peer presents. See [`auth`]. /// + /// The handle passed to [`Client::with_auth`](crate::Client::with_auth), or else one + /// this session created for itself, which ends with it. + /// /// On moq-lite-07-wip, and on moq-transport draft-17+ when both sides negotiate the /// MoQ Auth extension, each side presents its connection's credential right after /// setup. Older versions, and peers that do not negotiate it, leave the grant `None`. @@ -283,7 +286,7 @@ impl Session { pub(super) struct Handles { pub stats: crate::stats::Session, pub goaway: goaway::Handle, - pub auth: auth::Handle, + pub auth: auth::Link, /// Where the driver publishes [`Estimate`] changes, for a protocol that reports /// them to the peer (moq-lite PROBE). Sampled only while a consumer exists. pub estimate: Option>, @@ -307,6 +310,8 @@ impl Session { estimate, stats: context, } = handles; + let goaway = Arc::new(goaway); + auth.drain_with(goaway.clone()); let wire = session.wire().map(|counters| context.wire(counters)); let sample = snapshot(&session); if let Some(estimate) = &estimate @@ -346,6 +351,7 @@ impl Session { sampled: None, mode: SamplerMode::Idle, drain: Drain::Idle, + auth: auth.clone(), }; let session = Self { @@ -355,8 +361,8 @@ impl Session { version, send_bandwidth, recv_bandwidth, - goaway: Arc::new(goaway), - auth, + goaway, + auth: auth.handle().clone(), }; let driver = crate::Driver::new( runtime.clone(), @@ -403,6 +409,9 @@ pub(crate) struct Supervisor { drain: Drain, /// The transport's wire counters, registered under the session's root; dropping records the last reading. wire: Option, + /// This session's side of its auth handle, closed before the end is published so a + /// replacement session built on the same handle never finds this one still active. + auth: auth::Link, } /// A [`Session::close`] waiting for the protocol to deliver what it queued. @@ -443,11 +452,10 @@ impl Supervisor { Drain::Done(res) => Some(res), _ => None, }; + let err = self.version.transport_error(err); + self.auth.close(err.clone()); if let Ok(mut closed) = self.closed.write() { - *closed = Some(Ended { - err: self.version.transport_error(err), - drain, - }); + *closed = Some(Ended { err, drain }); } return Poll::Ready(()); } diff --git a/rs/moq-net/tests/integration/auth.rs b/rs/moq-net/tests/integration/auth.rs index 8f915dfb52b..630b710bc1f 100644 --- a/rs/moq-net/tests/integration/auth.rs +++ b/rs/moq-net/tests/integration/auth.rs @@ -70,6 +70,8 @@ cases!( a_live_subscription_survives_union_renewal, a_grant_update_too_large_for_one_message_is_unsupported, nothing_outside_the_grant_reaches_the_peer, + a_shared_handle_presents_its_token_on_every_session, + a_handle_backs_one_active_session, ); /// Run each case on moq-transport alone, whose namespace prefixes cannot carry every @@ -91,6 +93,12 @@ prefix_cases!( a_grant_too_large_for_one_message_is_unsupported, ); +/// moq-transport's AUTH_ERROR carries no code that may pass, so only lite retries. +#[moq_clock::test] +async fn lite_07_presents_a_transiently_refused_token_again() { + a_transient_refusal_is_presented_again(LITE_07).await +} + #[moq_clock::test] async fn lite_07_carries_pattern_grants() { pattern_grants_arrive_exactly(LITE_07).await @@ -145,6 +153,16 @@ async fn moqt_22_route_auth_renews_its_announce() { a_route_auth_renews_its_announce(MOQT_22).await } +#[moq_clock::test] +async fn moqt_16_route_auth_survives_a_reconnect() { + a_route_auth_survives_a_reconnect("moq-transport-16").await +} + +#[moq_clock::test] +async fn moqt_22_route_auth_survives_a_reconnect() { + a_route_auth_survives_a_reconnect(MOQT_22).await +} + #[moq_clock::test] async fn moqt_22_renewing_a_cancelled_announce_keeps_the_session() { within(async { @@ -280,24 +298,63 @@ async fn moqt_22_a_revoked_route_auth_remakes_its_announce() { /// 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 client_origin = produce_origin(2); + let server_origin = produce_origin(1); + let pair = connect_route_auth_on(version, &client_origin, &server_origin, None).await; + (pair, client_origin, server_origin) +} + +/// [`connect_route_auth`] between existing origins, optionally on a shared client handle. +async fn connect_route_auth_on( + version: &'static str, + client_origin: &origin::Producer, + server_origin: &origin::Producer, + client_auth: Option, +) -> Pair { 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 { + 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), + client_auth, ..Default::default() }) - .await; - (pair, client_origin, server_origin) + .await +} + +/// A route naming a shared handle's auth carries it on the next session too, with no +/// re-add or re-announce from the application. +async fn a_route_auth_survives_a_reconnect(version: &'static str) { + within(async { + let handle = auth::Handle::new(); + let client_origin = produce_origin(2); + let server_origin = produce_origin(1); + let mut first = connect_route_auth_on(version, &client_origin, &server_origin, Some(handle.clone())).await; + let mut tokens = serve(first.requests.take().unwrap(), |_| Some(Grant::all())); + let token = handle.add("first").await.expect("kept for requests"); + let broadcast = client_origin.create_broadcast("room/x").unwrap(); + broadcast.announce(origin::Route::default().with_auth(token)).unwrap(); + let (token, _first) = tokens.next().await.expect("the announce's token"); + assert_eq!(token, b"first"); + + first.server.abort(Error::Cancel); + first.client.closed().await; + + let mut second = connect_route_auth_on(version, &client_origin, &server_origin, Some(handle)).await; + let mut tokens = serve(second.requests.take().unwrap(), |_| Some(Grant::all())); + let (token, _second) = tokens.next().await.expect("the announce's token on the new session"); + assert_eq!(token, b"first"); + wait_announced(&server_origin.consume(), "room/x", true).await; + }) + .await + .expect("timed out"); } /// A route's auth reaches the peer's acceptor on the announcement, and swapping it @@ -402,6 +459,8 @@ struct Options { client_extensions: Option, server_extensions: Option, version: Option<&'static str>, + /// The handle the client builds its session on, when not its own. + client_auth: Option, } struct Pair { @@ -430,6 +489,9 @@ async fn connect(opts: Options) -> Pair { if let Some(subscribe) = opts.client_subscribe { client = client.with_subscriber(subscribe); } + if let Some(handle) = opts.client_auth { + client = client.with_auth(handle); + } let mut server = Server::new().with_versions(version.into()); if let Some(extensions) = opts.server_extensions { @@ -889,7 +951,7 @@ async fn dropping_the_requests_refuses_queued_tokens(version: &'static str) { .expect("timed out"); } -/// A closed session holds no grant: every token ended with it. +/// A closed session holds no grant: no session is live, and every token ended with it. async fn a_closed_session_holds_no_grant(version: &'static str) { within(async { let pair = connect(Options { @@ -903,7 +965,7 @@ async fn a_closed_session_holds_no_grant(version: &'static str) { let token = pair.client.auth().grant(); pair.server.abort(Error::Cancel); - wait_for(token, |g| g == &Some(Grant::default())).await; + wait_for(token, Option::is_none).await; }) .await .expect("timed out"); @@ -1782,3 +1844,130 @@ async fn a_widening_brings_back_what_the_peer_published(version: &'static str) { .await .expect("timed out"); } + +/// A handle passed to the client presents its token on every session it backs: added before +/// any session, granted on the first, cleared while disconnected, and granted again on the +/// next, without the token ending in between. +async fn a_shared_handle_presents_its_token_on_every_session(version: &'static str) { + within(async { + let handle = auth::Handle::new(); + let added = moq_clock::sim::spawn({ + let handle = handle.clone(); + async move { handle.add("token").await } + }); + let options = || Options { + version: Some(version), + client_publish: Some(produce_origin(2)), + server_requests: true, + client_auth: Some(handle.clone()), + ..Default::default() + }; + + let mut first = connect(options()).await; + let mut tokens = serve(first.requests.take().unwrap(), |token| match token { + b"" => Some(Grant::default()), + _ => Some(grant(&["first"], &[])), + }); + let token = added.await.expect("joined").expect("granted on the first session"); + let _first = presented(&mut tokens).await; + wait_for(token.grant(), |g| g == &Some(grant(&["first"], &[]))).await; + + first.server.abort(Error::Cancel); + first.client.closed().await; + assert_eq!(token.grant().peek(), None, "no session is live"); + assert!(futures::FutureExt::now_or_never(token.closed()).is_none(), "a session ending is no verdict"); + + let mut second = connect(options()).await; + let mut tokens = serve(second.requests.take().unwrap(), |token| match token { + b"" => Some(Grant::default()), + _ => Some(grant(&["second"], &[])), + }); + let _second = presented(&mut tokens).await; + wait_for(token.grant(), |g| g == &Some(grant(&["second"], &[]))).await; + wait_for(handle.grant(), |g| g == &Some(grant(&["second"], &[]))).await; + }) + .await + .expect("timed out"); +} + +/// The setup token and the handle's token, in either order, once both are presented. +async fn presented( + tokens: &mut futures::channel::mpsc::UnboundedReceiver<(Vec, auth::Issued)>, +) -> Vec<(Vec, auth::Issued)> { + let mut both = vec![ + tokens.next().await.expect("a token"), + tokens.next().await.expect("another token"), + ]; + both.sort_by(|a, b| a.0.cmp(&b.0)); + assert_eq!(both[0].0, b""); + assert_eq!(both[1].0, b"token"); + both +} + +/// A handle backs one active session: another fails loudly until the first drains. +async fn a_handle_backs_one_active_session(version: &'static str) { + within(async { + let handle = auth::Handle::new(); + let first = connect(Options { + version: Some(version), + client_auth: Some(handle.clone()), + ..Default::default() + }) + .await; + + let parsed: Version = version.parse().unwrap(); + let mut mock = moq_net::transport::memory::Config::default(); + mock.protocol = Some(parsed.alpn().to_string()); + let (transport, _peer) = create_mock_session_pair(mock); + let concurrent = Client::new() + .with_versions(parsed.into()) + .with_auth(handle.clone()) + .connect(now(), transport) + .await; + assert!(matches!(concurrent, Err(Error::Duplicate)), "{:?}", concurrent.err()); + + first.server.drain().send(moq_net::goaway::Goaway::new()).unwrap(); + first.client.draining().recv().await.expect("the GOAWAY"); + let _second = connect(Options { + version: Some(version), + client_auth: Some(handle), + ..Default::default() + }) + .await; + }) + .await + .expect("timed out"); +} + +/// A refusal that may pass, such as an overloaded verifier, presents the token again +/// after a backoff rather than ending it. +async fn a_transient_refusal_is_presented_again(version: &'static str) { + within(async { + let mut pair = connect(Options { + version: Some(version), + client_publish: Some(produce_origin(2)), + server_requests: true, + ..Default::default() + }) + .await; + let mut requests = pair.requests.take().unwrap(); + let (tx, mut issued) = futures::channel::mpsc::unbounded(); + moq_clock::sim::spawn(async move { + let mut refused = false; + while let Some(request) = requests.next().await { + if request.token().is_empty() || refused { + let _ = tx.unbounded_send(request.accept(Grant::default())); + } else { + refused = true; + request.reject(SessionError::TooManyRequests, "busy"); + } + } + }); + let token = pair.client.auth().add("token").await.expect("granted on the retry"); + assert_eq!(token.grant().peek(), Some(Grant::default())); + let _setup = issued.next().await; + let _token = issued.next().await; + }) + .await + .expect("timed out"); +} From 74995f38adcb032b3feece06a16551b3db5d3829 Mon Sep 17 00:00:00 2001 From: Luke Curley <432854+kixelated@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:32:32 -0700 Subject: [PATCH 04/45] wip(moq-tokio): build every connection session on one moq-net auth handle Co-Authored-By: Claude Opus 5.5 --- rs/moq-tokio/src/client.rs | 2 +- rs/moq-tokio/src/connection.rs | 108 ++++- rs/moq-tokio/src/connection/auth.rs | 721 ---------------------------- 3 files changed, 89 insertions(+), 742 deletions(-) delete mode 100644 rs/moq-tokio/src/connection/auth.rs diff --git a/rs/moq-tokio/src/client.rs b/rs/moq-tokio/src/client.rs index 446c9f91712..c1f6de77ed7 100644 --- a/rs/moq-tokio/src/client.rs +++ b/rs/moq-tokio/src/client.rs @@ -41,7 +41,7 @@ impl Config { /// Create via [`crate::connect::Config::init`] or [`Client::new`]. #[derive(Clone)] pub struct Client { - moq: moq_net::Client, + pub(crate) moq: moq_net::Client, /// The single resolved set of protocol versions, used to advertise moq ALPNs across /// every transport (passed into the QUIC backend's `connect` and used directly for /// raw TCP/UDS qmux and WebSocket). Resolved once in [`Client::new`] so the ALPN list diff --git a/rs/moq-tokio/src/connection.rs b/rs/moq-tokio/src/connection.rs index 623d443de72..3be71111588 100644 --- a/rs/moq-tokio/src/connection.rs +++ b/rs/moq-tokio/src/connection.rs @@ -1,7 +1,5 @@ //! The reconnect loop around a session, and the [`Monitor`] that observes it. -pub mod auth; - use std::task::{Poll, ready}; use std::time::Duration; @@ -462,8 +460,6 @@ impl Resolved { /// distinguishes a permanent give-up from a graceful close. #[derive(Default)] struct State { - /// Tokens to queue before publishing a replacement session to its consumers. - tokens: std::sync::Mutex>>, /// Current connection status, or `None` before the first connect. status: Option, /// How many sessions have been live on this handle, counting the current one. @@ -502,6 +498,8 @@ struct Shared { send_bw: BandwidthProducer, recv_bw: BandwidthProducer, closed: CloseGuard, + /// The auth handle every session is built on, ended with the loop. + auth: moq_net::auth::Handle, } impl Shared { @@ -515,7 +513,6 @@ impl Shared { return; } if let Ok(mut state) = self.state.write() { - auth::replay(&state, session, state.connects + 1); // A migration replaces a live session without a disconnect in between, so // its end is counted here, keeping `started - ended` at 1 while connected. if state.session.is_some() { @@ -589,9 +586,20 @@ impl Drop for Shared { // is what closes the transport. self.disconnected(); // Nothing retries anymore, however the loop ended. - if let Ok(mut state) = self.state.write() { - state.failure = None; - } + let failed = match self.state.write() { + Ok(mut state) => { + state.failure = None; + state.error.clone() + } + Err(state) => state.error.clone(), + }; + // No session will present the tokens again, so they end with the loop. + let aborted = self.closed.lock().unwrap().clone(); + self.auth.abort(match (aborted, failed) { + (Some(err), _) | (None, Some(Error::MoqNet(err))) => err, + (None, Some(err)) if err.is_auth() => moq_net::Error::Unauthorized, + _ => moq_net::Error::Cancel, + }); } } @@ -689,8 +697,8 @@ pub struct Snapshot { pub struct Connection { task: std::sync::Arc, state: kio::Consumer, - /// The reconnect pacing, which retained tokens reuse to retry a transient refusal. - pacing: Pacing, + /// The auth handle every session of the loop is built on. + auth: moq_net::auth::Handle, /// Persistent send-bitrate estimate, fed by the loop from each live session. send_bandwidth: BandwidthConsumer, /// Persistent recv-bitrate estimate, fed by the loop from each live session. @@ -718,10 +726,11 @@ struct Task { type CloseGuard = std::sync::Arc>>; impl Connection { - pub(crate) fn new(client: Client, addrs: Addrs) -> Self { + pub(crate) fn new(mut client: Client, addrs: Addrs) -> Self { let producer = kio::Producer::::default(); let state = producer.consume(); - let pacing = Pacing::new(&client.backoff); + let auth = moq_net::auth::Handle::new(); + client.moq = client.moq.with_auth(auth.clone()); // The loop feeds these across every reconnect, so a consumer's handle survives session churn // (unlike a session's own bandwidth consumer, which dies with the session). @@ -732,6 +741,7 @@ impl Connection { let closed: CloseGuard = Default::default(); let task_closed = closed.clone(); + let task_auth = auth.clone(); let task = tokio::spawn(async move { let reconnect = client.reconnect; @@ -740,6 +750,7 @@ impl Connection { send_bw, recv_bw, closed: task_closed, + auth: task_auth, }; // Isolated inside `shared`, so a panic still records a terminal error // instead of closing the channels as if the caller had stopped it. @@ -763,7 +774,7 @@ impl Connection { closed, }), state, - pacing, + auth, send_bandwidth, recv_bandwidth, last_reported: None, @@ -1256,13 +1267,12 @@ impl Connection { kio::wait(|waiter| self.poll_closed(waiter)).await } - /// 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(), - pacing: self.pacing, - } + /// The auth handle every session of this connection is built on: its tokens are + /// presented on each reconnect, so a route naming one keeps it, and its grant follows + /// the live session. It ends with the connection: [`Error::MoqNet`]'s error, or + /// [`moq_net::Error::Unauthorized`] for a rejection, or [`moq_net::Error::Cancel`]. + pub fn auth(&self) -> moq_net::auth::Handle { + self.auth.clone() } /// Observe the connection's statistics and presence across reconnects. @@ -2148,6 +2158,7 @@ mod tests { send_bw: BandwidthProducer::new(), recv_bw: BandwidthProducer::new(), closed: CloseGuard::default(), + auth: moq_net::auth::Handle::new(), } } @@ -2391,6 +2402,63 @@ mod tests { assert!(monitor.failure().is_none(), "nothing is retrying"); } + /// The connection's auth handle presents its token on every session the loop makes, + /// without the application adding it again. + #[cfg(feature = "tcp")] + #[tokio::test] + async fn a_token_is_presented_on_every_session() { + const WAIT: Duration = Duration::from_secs(10); + let version: moq_net::Version = "moq-lite-07-wip".parse().unwrap(); + + let mut config = crate::listen::Config::default(); + config.tcp.bind = Some("127.0.0.1:0".parse().unwrap()); + config.version = vec![version]; + let mut listener = config.init(Default::default()).unwrap().listen().await.unwrap(); + let addr = listener.tcp_local_addr().unwrap(); + + let mut config = crate::connect::Config::default(); + config.version = vec![version]; + config.backoff.initial = Some(MIN_BACKOFF); + config.backoff.max = Some(MIN_BACKOFF); + let connection = config.init(Default::default()).unwrap().connect(url(&format!("tcp://{addr}/"))); + let auth = connection.auth(); + let added = tokio::spawn({ + let auth = auth.clone(); + async move { auth.add("token").await } + }); + + let mut token = None; + for round in 0..2 { + let request = tokio::time::timeout(WAIT, listener.accept()).await.unwrap().unwrap(); + let mut requests = request.auth().requests().unwrap(); + let (session, _task) = request.ok().await.unwrap(); + let _issued = loop { + let request = tokio::time::timeout(WAIT, requests.next()).await.unwrap().unwrap(); + match request.token().as_ref() { + b"" => std::mem::forget(request.accept(moq_net::auth::Grant::default())), + token => { + assert_eq!(token, b"token", "round {round}"); + break request.accept(moq_net::auth::Grant::all()); + } + } + }; + let token = match token.take() { + Some(token) => token, + None => added.await.unwrap().expect("granted"), + }; + let mut grant = token.grant(); + while grant.peek() != Some(moq_net::auth::Grant::all()) { + tokio::time::timeout(WAIT, grant.changed()).await.unwrap().unwrap(); + } + session.abort(moq_net::Error::Cancel); + token = Some(token); + } + drop(connection); + let token = token.unwrap(); + let closed = tokio::time::timeout(WAIT, token.closed()).await.unwrap(); + assert!(matches!(closed, moq_net::Error::Cancel), "{closed:?}"); + } + /// A session that ends the loop reports through `closed`, not as a retry reason. #[cfg(feature = "tcp")] #[tokio::test] diff --git a/rs/moq-tokio/src/connection/auth.rs b/rs/moq-tokio/src/connection/auth.rs deleted file mode 100644 index f6e5a194b24..00000000000 --- a/rs/moq-tokio/src/connection/auth.rs +++ /dev/null @@ -1,721 +0,0 @@ -//! Tokens and grant watches that follow a reconnecting connection. - -use std::{ - sync::{Arc, Mutex}, - task::{Poll, ready}, -}; - -use bytes::Bytes; -use futures::FutureExt; -use moq_net::{auth, kio}; - -use super::{Pacing, State, terminal}; -use crate::{Error, abort::AbortOnDrop}; - -/// Present session tokens across every session of a connection. -#[derive(Clone)] -pub struct Handle { - pub(super) connection: kio::Consumer, - pub(super) pacing: Pacing, -} - -impl Handle { - /// Watch the current session's union, clearing it while disconnected. - pub fn grant(&self) -> Watch { - Watch { - source: Source::Connection(self.connection.clone()), - last: None, - } - } - - /// 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 { - let producer = kio::Producer::::default(); - let state = producer.consume(); - let connection = self.connection.clone(); - let pacing = self.pacing; - let token = token.into(); - let replay = Arc::new(Replay { - bytes: token, - queued: Mutex::new(Vec::new()), - }); - { - let state = connection.read(); - let mut tokens = state.tokens.lock().unwrap(); - tokens.retain(|token| token.strong_count() > 0); - if let Some(session) = &state.session { - replay.queue(session, state.connects); - } - tokens.push(Arc::downgrade(&replay)); - } - let (answered, answer) = tokio::sync::oneshot::channel(); - let task = AbortOnDrop::new(tokio::spawn(async move { - let mut answered = Some(answered); - if let Err(err) = present(connection, pacing, replay, &producer, &mut answered).await { - if let Some(answered) = answered.take() { - let _ = answered.send(Err(err.clone())); - } - if let Ok(mut state) = producer.write() { - state.grant = None; - state.error = Some(err); - } - } - })); - answer.await.map_err(|_| Error::from(moq_net::Error::Cancel))??; - Ok(Auth { _task: task, state }) - } -} - -type Opening = futures::future::BoxFuture<'static, moq_net::Result>; - -/// Registration shared with session publication, while the task owns its token's life. -pub(super) struct Replay { - bytes: Bytes, - /// Registrations queued as sessions are published, until the task takes them. - queued: Mutex>, -} - -struct Queued { - connects: u64, - session: moq_net::Session, - opening: Opening, -} - -impl Replay { - fn queue(&self, session: &moq_net::Session, connects: u64) { - let handle = session.auth(); - let bytes = self.bytes.clone(); - let mut opening = async move { handle.add(bytes).await }.boxed(); - // The first poll registers the token synchronously, before the session can be - // observed. The replay task replaces this noop waker when it takes the future. - if let Some(result) = opening.as_mut().now_or_never() { - opening = futures::future::ready(result).boxed(); - } - self.queued.lock().unwrap().push(Queued { - connects, - session: session.clone(), - opening, - }); - } - - /// Take the registration queued for session `connects`, moving older ones to `draining`. - fn take(&self, connects: u64, draining: &mut Draining) -> Option { - let mut queued = self.queued.lock().unwrap(); - let mut current = None; - for entry in std::mem::take(&mut *queued) { - match entry.connects.cmp(&connects) { - // Requests on a draining session may still be waiting on this answer. - std::cmp::Ordering::Less => draining.push(entry.session, entry.opening), - std::cmp::Ordering::Equal => current = Some(entry.opening), - // A replacement published since the task's snapshot already holds this token. - // Dropping its registration would withdraw the token from a session consumers - // can see, so leave it for the task's next pass. - std::cmp::Ordering::Greater => queued.push(entry), - } - } - current - } -} - -/// Tokens kept on sessions draining after a migration. Each is held by a task that -/// releases it, and its session, once that session ends; dropping this withdraws them all. -#[derive(Default)] -struct Draining(Vec); - -impl Draining { - /// Keep `held`, a token answered or not, registered on `session` until it ends. - fn push(&mut self, session: moq_net::Session, held: impl Send + 'static) { - self.0.retain(|task| !task.is_finished()); - self.0.push(AbortOnDrop::new(tokio::spawn(async move { - session.closed().await; - drop(held); - }))); - } -} - -pub(super) fn replay(state: &State, session: &moq_net::Session, connects: u64) { - state.tokens.lock().unwrap().retain(|token| { - let Some(token) = token.upgrade() else { - return false; - }; - token.queue(session, connects); - true - }); -} - -/// A presented token; dropping it withdraws it from live and draining sessions. -pub struct Auth { - _task: AbortOnDrop, - state: kio::Consumer, -} - -impl Auth { - /// Watch this token's grant on the current session. - pub fn grant(&self) -> Watch { - Watch { - source: Source::Auth(self.state.clone()), - last: None, - } - } - - /// Wait until the token is refused or its connection ends. - pub async fn closed(&self) -> crate::Result<()> { - kio::wait(|waiter| self.state.poll_closed(waiter)).await; - match &self.state.read().error { - Some(err) => Err(err.clone()), - None => Ok(()), - } - } -} - -#[derive(Default)] -struct Granted { - grant: Option, - error: Option, -} - -#[derive(Clone)] -enum Source { - Connection(kio::Consumer), - Auth(kio::Consumer), -} - -/// Observe a grant as tokens change and the connection replaces its session. -#[derive(Clone)] -pub struct Watch { - source: Source, - last: Option, -} - -impl Watch { - /// The current grant, or none while disconnected or after the token ends. - pub fn peek(&self) -> Option { - match &self.source { - Source::Connection(connection) => { - if connection.poll_closed(&kio::Waiter::noop()).is_ready() { - return None; - } - connection - .read() - .session - .as_ref() - .and_then(|session| session.auth().grant().peek()) - } - Source::Auth(state) => { - if state.poll_closed(&kio::Waiter::noop()).is_ready() { - return None; - } - state.read().grant.clone() - } - } - } - - /// Poll for a different grant or the end of its owner. - pub fn poll_changed(&mut self, waiter: &kio::Waiter) -> Poll>> { - let result = match &self.source { - Source::Connection(connection) => match ready!(connection.poll(waiter, |state| { - let mut watch = state.session.as_ref().map(|session| session.auth().grant()); - let current = watch.as_ref().and_then(auth::Watch::peek); - if current != self.last { - return Poll::Ready(current); - } - if let Some(watch) = &mut watch - && let Poll::Ready(Ok(grant)) = watch.poll_changed(waiter) - { - return Poll::Ready(grant); - } - Poll::Pending - })) { - Ok(grant) => Ok(grant), - Err(state) => Err(terminal(&state)), - }, - Source::Auth(state) => match ready!(state.poll(waiter, |state| { - if state.grant != self.last { - Poll::Ready(state.grant.clone()) - } else { - Poll::Pending - } - })) { - Ok(grant) => Ok(grant), - Err(state) => Err(state.error.clone().unwrap_or_else(|| moq_net::Error::Cancel.into())), - }, - }; - if let Ok(grant) = &result { - self.last = grant.clone(); - } - Poll::Ready(result) - } - - /// Wait for a different grant or the end of its owner. - pub async fn changed(&mut self) -> crate::Result> { - kio::wait(|waiter| self.poll_changed(waiter)).await - } -} - -async fn next( - connection: &kio::Consumer, - previous: Option<(u64, bool)>, -) -> crate::Result<(u64, Option)> { - kio::wait(|waiter| { - match ready!(connection.poll(waiter, |state| { - if Some((state.connects, state.session.is_some())) != previous { - Poll::Ready((state.connects, state.session.clone())) - } else { - Poll::Pending - } - })) { - Ok(session) => Poll::Ready(Ok(session)), - Err(state) => Poll::Ready(Err(terminal(&state))), - } - }) - .await -} - -/// Whether a refusal is the acceptor failing to decide rather than its verdict on -/// the token, such as an unreachable auth server or a full verification queue. -/// moq-transport's AUTH_ERROR carries neither, so there every refusal is final. -fn transient(err: &moq_net::Error) -> bool { - matches!( - err, - moq_net::Error::Session( - moq_net::SessionError::Internal | moq_net::SessionError::TooManyRequests | moq_net::SessionError::Timeout - ) - ) -} - -async fn present( - connection: kio::Consumer, - pacing: Pacing, - replay: Arc, - producer: &kio::Producer, - answered: &mut Option>>, -) -> crate::Result<()> { - let mut snapshot = next(&connection, None).await?; - let mut draining = Draining::default(); - 'session: loop { - let (connects, session) = snapshot; - let Some(session) = session else { - if let Ok(mut state) = producer.write() { - state.grant = None; - } - snapshot = next(&connection, Some((connects, false))).await?; - continue; - }; - if let Ok(mut state) = producer.write() { - state.grant = None; - } - let mut delay = None; - let mut opening = replay.take(connects, &mut draining); - let token = loop { - let attempt = async { - if opening.is_none() { - if let Some(delay) = delay { - tokio::time::sleep(delay).await; - } - let handle = session.auth(); - let bytes = replay.bytes.clone(); - opening = Some(async move { handle.add(bytes).await }.boxed()); - } - opening.as_mut().expect("opening").await - }; - let result = tokio::select! { - biased; - changed = next(&connection, Some((connects, true))) => Err(changed), - result = attempt => Ok(result), - }; - let result = match result { - Ok(result) => result, - Err(changed) => { - // Requests on a draining session may still be waiting on this answer. - if let Some(opening) = opening.take() { - draining.push(session, opening); - } - snapshot = changed?; - continue 'session; - } - }; - opening = None; - match result { - Ok(token) => break token, - // The session ending is no verdict on the token: replay it on the next one. - Err(_) if session.closed().now_or_never().is_some() => { - snapshot = next(&connection, Some((connects, true))).await?; - continue 'session; - } - // Nor is an acceptor that could not decide: present it again, paced like a - // reconnect. Only an accepted grant counts, so a retry never extends one. - Err(err) if transient(&err) => { - let wait = delay.map_or(pacing.initial, |delay| pacing.next(delay)); - tracing::debug!(%err, ?wait, "token refused transiently; presenting it again"); - delay = Some(wait); - } - Err(err) => return Err(err.into()), - } - }; - if let Some(answered) = answered.take() { - let _ = answered.send(Ok(())); - } - let mut watch = token.grant(); - if let Ok(mut state) = producer.write() { - state.grant = watch.peek(); - } - loop { - tokio::select! { - biased; - changed = next(&connection, Some((connects, true))) => { snapshot = changed?; break; } - _ = session.closed() => { - if let Ok(mut state) = producer.write() { state.grant = None; } - snapshot = next(&connection, Some((connects, true))).await?; - break; - } - grant = watch.changed() => match grant { - Ok(grant) => { - if let Ok(mut state) = producer.write() { state.grant = grant; } - } - // The stream ended with its session, not by the peer's verdict. - Err(_) if session.closed().now_or_never().is_some() => { - if let Ok(mut state) = producer.write() { state.grant = None; } - snapshot = next(&connection, Some((connects, true))).await?; - break; - } - Err(err) => return Err(err.into()), - } - } - } - // 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.push(session, token); - } -} - -#[cfg(all(test, feature = "tcp"))] -mod tests { - use super::*; - - struct Pair { - client: moq_net::Session, - requests: tokio::sync::mpsc::UnboundedReceiver, - _tasks: Vec>, - _acceptor: AbortOnDrop, - } - - async fn pair() -> Pair { - let versions = moq_net::Versions::from("moq-lite-07-wip".parse::().unwrap()); - let config = || { - let mut config = qmux::Config::new(qmux::Version::QMux01); - config.protocol = qmux::Protocol::Negotiate(versions.alpns().iter().map(|alpn| alpn.to_string()).collect()); - config - }; - let stream = |io| qmux::transport::Stream::new(io, qmux::Version::QMux01, config().max_record_size); - let (client, server) = tokio::io::duplex(64 * 1024); - let (client, server) = tokio::try_join!( - qmux::Session::connect(stream(client), config()), - qmux::Session::accept(stream(server), config()) - ) - .unwrap(); - let now = moq_net::time::now(); - let client = async { - let (session, driver) = moq_net::Client::new() - .with_versions(versions.clone()) - .connect( - now, - crate::transport::Session::new(client, moq_net::transport::Binding::InBand), - ) - .await - .unwrap(); - (session, AbortOnDrop::new(tokio::spawn(moq_net::time::run(driver)))) - }; - let server = async { - let request = moq_net::Server::new() - .with_versions(versions.clone()) - .accept_request( - now, - crate::transport::Session::new(server, moq_net::transport::Binding::InBand), - ) - .await - .unwrap(); - let mut requests = request.auth().requests().unwrap(); - let (session, driver) = request.ok().await.unwrap(); - let (sender, receiver) = tokio::sync::mpsc::unbounded_channel(); - let acceptor = tokio::spawn(async move { - let _session = session; - let mut initial = Vec::new(); - while let Some(request) = requests.next().await { - if request.token().is_empty() { - initial.push(request.accept(auth::Grant::default())); - } else if sender.send(request).is_err() { - break; - } - } - }); - ( - receiver, - AbortOnDrop::new(tokio::spawn(moq_net::time::run(driver))), - AbortOnDrop::new(acceptor), - ) - }; - let ((client, client_task), (requests, server_task, acceptor)) = tokio::join!(client, server); - Pair { - client, - requests, - _tasks: vec![client_task, server_task], - _acceptor: acceptor, - } - } - - fn attach(connection: &kio::Producer, session: &moq_net::Session) { - let mut state = connection.write().ok().expect("connection observed"); - state.connects += 1; - replay(&state, session, state.connects); - state.session = Some(session.clone()); - } - - #[tokio::test(start_paused = true)] - async fn replay_is_registered_before_the_session_is_published() { - let connection = kio::Producer::::default(); - let auth = retained(&connection); - let mut first = pair().await; - attach(&connection, &first.client); - let (token, _issued) = tokio::join!(auth.add("replay"), async { - first.requests.recv().await.unwrap().accept(auth::Grant::all()) - }); - let token = token.unwrap(); - let mut second = pair().await; - let connects = { - let state = connection.read(); - let connects = state.connects + 1; - replay(&state, &second.client, connects); - connects - }; - // The task still observes the first session. The second token reaches the peer - // before publication only if queueing it already polled add's registration. - let request = tokio::time::timeout(std::time::Duration::from_secs(1), second.requests.recv()) - .await - .expect("token was not queued before publication") - .unwrap(); - assert_eq!(request.token().as_ref(), b"replay"); - { - let mut state = connection.write().ok().expect("connection observed"); - state.connects = connects; - state.session = Some(second.client.clone()); - } - let _issued = request.accept(auth::Grant::all()); - let mut watch = token.grant(); - while watch.peek().is_none() { - watch.changed().await.unwrap(); - } - } - - #[tokio::test(start_paused = true)] - async fn a_stale_snapshot_keeps_the_newer_sessions_registration() { - let mut first = pair().await; - let mut second = pair().await; - let replay = Replay { - bytes: "replay".into(), - queued: Mutex::new(Vec::new()), - }; - let mut draining = Draining::default(); - // The replacement queues and publishes while the task still holds the first snapshot. - replay.queue(&second.client, 2); - let request = second.requests.recv().await.unwrap(); - assert!(replay.take(1, &mut draining).is_none()); - let opening = replay - .take(2, &mut draining) - .expect("the newer session's registration was dropped"); - let _issued = request.accept(auth::Grant::all()); - let _token = opening.await.unwrap(); - // A registration for a session the task has moved past stays on it while it drains. - replay.queue(&first.client, 1); - let _stale = first.requests.recv().await.unwrap(); - assert!(replay.take(2, &mut draining).is_none()); - assert!(replay.queued.lock().unwrap().is_empty()); - assert_eq!(draining.0.len(), 1, "the draining session lost its registration"); - } - - /// A draining session that closes releases its token and its session handle at once, - /// not only when a later migration adds another. - #[tokio::test(start_paused = true)] - async fn a_closed_draining_session_releases_its_token() { - let pair = pair().await; - let mut draining = Draining::default(); - let held = Arc::new(()); - draining.push(pair.client.clone(), held.clone()); - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - assert_eq!(Arc::strong_count(&held), 2, "released while its session lives"); - pair.client.abort(moq_net::Error::Cancel); - pair.client.closed().await; - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - assert_eq!(Arc::strong_count(&held), 1, "a closed draining session kept its token"); - drop(draining); - } - - /// A migration keeps an unanswered replay on the session it leaves, whether or not the - /// task had taken it, since requests there may still be waiting on its answer. - #[tokio::test(start_paused = true)] - async fn a_draining_session_keeps_an_unanswered_replay() { - for taken in [false, true] { - let connection = kio::Producer::::default(); - let auth = retained(&connection); - let mut first = pair().await; - attach(&connection, &first.client); - let (token, _first_grant) = tokio::join!(auth.add("renew"), async { - first.requests.recv().await.unwrap().accept(auth::Grant::all()) - }); - let token = token.unwrap(); - let mut second = pair().await; - let third = pair().await; - attach(&connection, &second.client); - if taken { - // Let the task take the queued registration and wait on its answer. - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - } - attach(&connection, &third.client); - let request = tokio::time::timeout(std::time::Duration::from_secs(5), second.requests.recv()) - .await - .unwrap_or_else(|_| panic!("taken={taken}: the draining session withdrew its token unsent")) - .unwrap(); - assert_eq!(request.token().as_ref(), b"renew"); - let _issued = request.accept(auth::Grant::all()); - let mut grant = second.client.auth().grant(); - tokio::time::timeout(std::time::Duration::from_secs(5), async { - while grant.peek() != Some(auth::Grant::all()) { - grant.changed().await.unwrap(); - } - }) - .await - .unwrap_or_else(|_| panic!("taken={taken}: the draining session never applied its token")); - tokio::time::sleep(std::time::Duration::from_secs(1)).await; - assert_eq!( - second.client.auth().grant().peek(), - Some(auth::Grant::all()), - "taken={taken}: the draining session withdrew its token" - ); - drop(token); - } - } - - #[tokio::test(start_paused = true)] - async fn a_token_survives_replacement_and_withdraws_from_both_sessions() { - let connection = kio::Producer::::default(); - let auth = retained(&connection); - let mut first = pair().await; - attach(&connection, &first.client); - let (token, first_grant) = tokio::join!(auth.add("renew"), async { - let request = first.requests.recv().await.unwrap(); - assert_eq!(request.token().as_ref(), b"renew"); - request.accept(auth::Grant::all()) - }); - let token = token.unwrap(); - let mut grant = token.grant(); - assert!(grant.changed().await.unwrap().is_some()); - let mut second = pair().await; - attach(&connection, &second.client); - let request = second.requests.recv().await.unwrap(); - assert_eq!(request.token().as_ref(), b"renew"); - assert!( - grant.changed().await.unwrap().is_none(), - "replacement reused its predecessor grant" - ); - let second_grant = request.accept(auth::Grant::all()); - assert!(grant.changed().await.unwrap().is_some()); - assert!( - first_grant.closed().now_or_never().is_none(), - "migration withdrew a draining grant" - ); - drop(token); - let _ = tokio::join!(first_grant.closed(), second_grant.closed()); - } - - #[tokio::test(start_paused = true)] - async fn a_session_closing_mid_replay_keeps_the_token() { - let connection = kio::Producer::::default(); - let auth = retained(&connection); - let mut first = pair().await; - attach(&connection, &first.client); - let (token, _first_grant) = tokio::join!(auth.add("renew"), async { - first.requests.recv().await.unwrap().accept(auth::Grant::all()) - }); - let token = token.unwrap(); - let mut second = pair().await; - attach(&connection, &second.client); - // Close the replacement while its replayed token is still unanswered, before - // the connection notices. - let _unanswered = second.requests.recv().await.unwrap(); - second.client.abort(moq_net::Error::Cancel); - second.client.closed().await; - let mut third = pair().await; - attach(&connection, &third.client); - let request = tokio::time::timeout(std::time::Duration::from_secs(5), third.requests.recv()) - .await - .expect("the token was not replayed after its session closed") - .unwrap(); - assert_eq!(request.token().as_ref(), b"renew"); - drop(token); - } - - fn retained(connection: &kio::Producer) -> Handle { - Handle { - connection: connection.consume(), - pacing: Pacing::new(&super::super::Backoff::default()), - } - } - - #[tokio::test(start_paused = true)] - async fn a_transient_refusal_is_presented_again_after_backoff() { - let connection = kio::Producer::::default(); - let auth = retained(&connection); - let mut pair = pair().await; - attach(&connection, &pair.client); - let start = tokio::time::Instant::now(); - let (token, issued) = tokio::join!(auth.add("renew"), async { - for _ in 0..2 { - let outage = pair.requests.recv().await.unwrap(); - outage.reject(moq_net::SessionError::Internal, "auth server unavailable"); - } - let retried = pair.requests.recv().await.unwrap(); - assert_eq!(retried.token().as_ref(), b"renew"); - retried.accept(auth::Grant::all()) - }); - let token = token.unwrap(); - // The default pacing waits 1s, then 2s, before the third presentation. - assert!( - start.elapsed() >= std::time::Duration::from_secs(3), - "retried without backing off: {:?}", - start.elapsed() - ); - assert!(token.grant().changed().await.unwrap().is_some()); - drop(token); - issued.closed().await; - } - - #[tokio::test(start_paused = true)] - async fn an_unauthorized_refusal_is_final() { - let connection = kio::Producer::::default(); - let auth = retained(&connection); - let mut pair = pair().await; - attach(&connection, &pair.client); - let (token, ()) = tokio::join!(auth.add("renew"), async { - let refused = pair.requests.recv().await.unwrap(); - refused.reject(moq_net::SessionError::Unauthorized, "token refused"); - }); - assert!(token.is_err()); - tokio::time::sleep(std::time::Duration::from_secs(60)).await; - assert!(pair.requests.try_recv().is_err(), "a refused token was presented again"); - } - - #[tokio::test(start_paused = true)] - async fn ending_the_connection_withdraws_a_retained_token_handle() { - let connection = kio::Producer::::default(); - let auth = retained(&connection); - let mut pair = pair().await; - attach(&connection, &pair.client); - let (token, issued) = tokio::join!(auth.add("renew"), async { - pair.requests.recv().await.unwrap().accept(auth::Grant::all()) - }); - let token = token.unwrap(); - drop(connection); - assert!(token.closed().await.is_err()); - assert!(token.grant().peek().is_none()); - issued.closed().await; - } -} From 549ee5d9144b2e7e4a365b6c9b71f5aeb680a90e Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 20 Oct 2026 16:55:16 -0700 Subject: [PATCH 05/45] fix(auth): finish the moq-tokio half of the shared auth handle Fix the reconnect test, which shadowed its token and never compiled under `--all-targets` with tcp; its OOM was the build on a loaded machine, not the test (it now runs in 0.15s). Hold issued grants instead of leaking them. Extend `a_handle_backs_one_active_session` to cover a draining session keeping an unanswered token beside its successor, and a token added after the drain riding only the successor. Point the relay auth bench at `moq_net::auth`, update the relay refresh tests for a transient refusal that now retries instead of failing `add()`, add MIGRATE entries for moq-net and moq-tokio, and apply rustfmt. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/MIGRATE.md | 10 +++++ rs/moq-net/src/auth.rs | 22 +++++++--- rs/moq-net/src/ietf/session.rs | 5 +-- rs/moq-net/src/lite/session.rs | 13 ++++-- rs/moq-net/tests/integration/auth.rs | 66 +++++++++++++++++++++++----- rs/moq-relay/benches/auth.rs | 6 +-- rs/moq-relay/src/auth/refresh.rs | 50 ++++++++++++--------- rs/moq-tokio/MIGRATE.md | 7 +++ rs/moq-tokio/src/connection.rs | 35 ++++++++------- 9 files changed, 153 insertions(+), 61 deletions(-) diff --git a/rs/moq-net/MIGRATE.md b/rs/moq-net/MIGRATE.md index f86f29361e9..ed06f5b5f31 100644 --- a/rs/moq-net/MIGRATE.md +++ b/rs/moq-net/MIGRATE.md @@ -4,6 +4,16 @@ How to update your code for each breaking change, newest first. ## Unreleased +### An auth handle can outlive its session (#6109) + +`auth::Handle::new()` creates a handle that backs no session yet; pass it to `Client::with_auth` and every session that client connects presents the tokens it holds. +A `Route` naming one of its `Auth`s keeps that token across a reconnect, so drop the code that added the token again and re-announced after one. +The handle backs one active session at a time, plus the ones draining after a GOAWAY: connecting another while one is still active fails with `Error::Duplicate`. Give independent concurrent sessions separate handles. +`Handle::grant()` and `Auth::grant()` follow the newest open session, so they read `None` while disconnected instead of keeping the last session's grant. +`Auth::closed()` resolves only on the peer's verdict (a refusal, `Unsupported`, or a clean close after a grant) or the handle's end, which `Handle::abort` triggers; a session ending is not one. +A handle a session creates for itself still ends with that session. +On moq-lite, a token refused with a code that may pass (`Internal`, `TooManyRequests`, `Timeout`) is presented again after a backoff of 1s doubling to 30s, instead of `add` failing with that code. + ### Each group has one writer (#6020) `group::Producer` no longer implements `Clone`: keep one writer and move it by value between owners, or put it behind a lock when several callers write it. diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index 0448cbba544..1a5376b5b3c 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -816,7 +816,10 @@ impl Link { /// Read this session's side of the handle, and the handle's state. fn read(&self, f: impl FnOnce(&Attached, &State) -> R) -> R { let state = self.state().read(); - let link = state.links.get(&self.id()).expect("a link outlives its session's state"); + let link = state + .links + .get(&self.id()) + .expect("a link outlives its session's state"); f(link, &state) } @@ -2676,7 +2679,10 @@ mod tests { 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.read(|link, _| link.opening.is_empty()), "nothing to open a stream for"); + assert!( + ietf.read(|link, _| link.opening.is_empty()), + "nothing to open a stream for" + ); let lite = Link::new(false); let auth = lite.present(Bytes::from_static(b"jwt"), false).unwrap(); @@ -2769,7 +2775,10 @@ mod tests { second.close(Error::Cancel); let third = Link::attach(&handle, true, false).unwrap(); - assert!(third.poll_opening(&kio::Waiter::noop()).is_pending(), "a refused token stays ended"); + assert!( + third.poll_opening(&kio::Waiter::noop()).is_pending(), + "a refused token stays ended" + ); } /// A refusal that may pass is presented again after a doubling backoff, and settles nothing. @@ -2792,7 +2801,6 @@ mod tests { link.granted(id, Grant::all()); assert!(matches!(auth.poll_answered(&kio::Waiter::noop()), Poll::Ready(Ok(())))); } - } #[cfg(all(test, feature = "async"))] @@ -2960,7 +2968,11 @@ mod request_token_tests { verdict.grant().await.expect("granted"); // The union only reflects tokens presented on this side (none here), never a // request token the acceptor answered. - assert_eq!(handle.handle().grant().peek(), None, "a request token must not widen the union"); + assert_eq!( + handle.handle().grant().peek(), + None, + "a request token must not widen the union" + ); } fn subscribe_only(prefix: &str) -> Grant { diff --git a/rs/moq-net/src/ietf/session.rs b/rs/moq-net/src/ietf/session.rs index 17b732f07f7..006cab78408 100644 --- a/rs/moq-net/src/ietf/session.rs +++ b/rs/moq-net/src/ietf/session.rs @@ -2232,10 +2232,7 @@ mod tests { #[moq_clock::test] async fn a_server_offers_auth_only_to_a_client_that_did() { let all = crate::setup::Extensions::default(); - assert_eq!( - our_setup(false, crate::auth::Link::new(true), all).await, - (true, true) - ); + assert_eq!(our_setup(false, crate::auth::Link::new(true), all).await, (true, true)); assert_eq!( our_setup(false, crate::auth::Link::new(false), all).await, (false, true) diff --git a/rs/moq-net/src/lite/session.rs b/rs/moq-net/src/lite/session.rs index c0af2829995..5dd49ff67f9 100644 --- a/rs/moq-net/src/lite/session.rs +++ b/rs/moq-net/src/lite/session.rs @@ -527,10 +527,17 @@ struct PresentToken { } enum PresentState { - Open { token: bytes::Bytes }, - Run { stream: Stream, answered: bool }, + Open { + token: bytes::Bytes, + }, + Run { + stream: Stream, + answered: bool, + }, /// Refused for a reason that may pass: present it again once the deadline fires. - Retry { deadline: crate::time::Deadline }, + Retry { + deadline: crate::time::Deadline, + }, Done, } diff --git a/rs/moq-net/tests/integration/auth.rs b/rs/moq-net/tests/integration/auth.rs index 630b710bc1f..749e31e6c92 100644 --- a/rs/moq-net/tests/integration/auth.rs +++ b/rs/moq-net/tests/integration/auth.rs @@ -1875,7 +1875,10 @@ async fn a_shared_handle_presents_its_token_on_every_session(version: &'static s first.server.abort(Error::Cancel); first.client.closed().await; assert_eq!(token.grant().peek(), None, "no session is live"); - assert!(futures::FutureExt::now_or_never(token.closed()).is_none(), "a session ending is no verdict"); + assert!( + futures::FutureExt::now_or_never(token.closed()).is_none(), + "a session ending is no verdict" + ); let mut second = connect(options()).await; let mut tokens = serve(second.requests.take().unwrap(), |token| match token { @@ -1904,16 +1907,38 @@ async fn presented( both } -/// A handle backs one active session: another fails loudly until the first drains. +/// A handle backs one active session: another fails loudly until the first drains. The +/// draining session keeps presenting the handle's tokens beside its successor, even one +/// its peer has not answered yet, since its requests may still rely on them. A token added +/// after the drain rides only the successor: a draining session opens no new streams. async fn a_handle_backs_one_active_session(version: &'static str) { within(async { + use futures::FutureExt; + let handle = auth::Handle::new(); - let first = connect(Options { + let options = || Options { version: Some(version), + client_publish: Some(produce_origin(2)), + server_requests: true, client_auth: Some(handle.clone()), ..Default::default() - }) - .await; + }; + let mut first = connect(options()).await; + let mut first_requests = first.requests.take().unwrap(); + let added = moq_clock::sim::spawn({ + let handle = handle.clone(); + async move { handle.add("token").await } + }); + let mut first_issued = Vec::new(); + let unanswered = loop { + let request = first_requests.next().await.expect("a token"); + if request.token().is_empty() { + first_issued.push(request.accept(Grant::default())); + continue; + } + assert_eq!(request.token().as_ref(), b"token"); + break request; + }; let parsed: Version = version.parse().unwrap(); let mut mock = moq_net::transport::memory::Config::default(); @@ -1928,12 +1953,31 @@ async fn a_handle_backs_one_active_session(version: &'static str) { first.server.drain().send(moq_net::goaway::Goaway::new()).unwrap(); first.client.draining().recv().await.expect("the GOAWAY"); - let _second = connect(Options { - version: Some(version), - client_auth: Some(handle), - ..Default::default() - }) - .await; + let mut second = connect(options()).await; + let mut second_tokens = serve(second.requests.take().unwrap(), |_| Some(Grant::all())); + let _second_issued = presented(&mut second_tokens).await; + let _token = added.await.expect("joined").expect("granted on the second session"); + + moq_clock::sleep(Duration::from_secs(1)).await; + assert!( + unanswered.closed().now_or_never().is_none(), + "the draining session withdrew its unanswered token" + ); + let first_grant = unanswered.accept(Grant::all()); + moq_clock::sleep(Duration::from_secs(1)).await; + assert!( + first_grant.closed().now_or_never().is_none(), + "the draining session dropped its late grant" + ); + + let later = handle.add("later").await.expect("granted on the second session"); + let (token, _later) = second_tokens.next().await.expect("the active session presents it"); + assert_eq!(token, b"later"); + assert!( + first_requests.next().now_or_never().is_none(), + "the draining session opened a new stream" + ); + drop(later); }) .await .expect("timed out"); diff --git a/rs/moq-relay/benches/auth.rs b/rs/moq-relay/benches/auth.rs index 0dd360d2458..9160876dd00 100644 --- a/rs/moq-relay/benches/auth.rs +++ b/rs/moq-relay/benches/auth.rs @@ -16,10 +16,10 @@ fn grant(prefix: &str) -> Grant { struct Fixture { _connections: Vec, - _tokens: Vec, + _tokens: Vec, producers: Vec, selected: usize, - watch: moq_tokio::connection::auth::Watch, + watch: moq_net::auth::Watch, tasks: Vec>, alternate: bool, } @@ -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::Handle, + auth: moq_net::auth::Handle, admissions: auth::Admissions, held: std::collections::VecDeque, presented: usize, diff --git a/rs/moq-relay/src/auth/refresh.rs b/rs/moq-relay/src/auth/refresh.rs index 1e03db153f4..ed7e0028956 100644 --- a/rs/moq-relay/src/auth/refresh.rs +++ b/rs/moq-relay/src/auth/refresh.rs @@ -807,16 +807,21 @@ mod tests { let clock = manual(); let (lease, mut admissions) = admission(grant("a/**", 30)).await; let (client, mut supervising) = sessions(lease).await; - for _ in 0..3 { - let auth = client.auth(); - let (result, ()) = tokio::join!(auth.add("replacement"), async { - admissions - .next() - .await - .unwrap() - .refuse(super::super::Error::Unavailable("auth server down".into())); - }); - assert!(matches!(result, Err(moq_net::Error::Session(SessionError::Internal)))); + let mut adding = present(&client, "replacement"); + for attempt in 0..3 { + let admission = admissions.next().await.unwrap(); + assert_eq!( + admission.request.query.as_deref(), + Some("jwt=replacement"), + "attempt {attempt}" + ); + admission.refuse(super::super::Error::Unavailable("auth server down".into())); + // The refusal may pass, so the client presents the token again after a backoff + // shorter than each step below, rather than ending it. + assert!( + settles_pending(&mut adding).await, + "a transient refusal ended the token" + ); clock.advance(Duration::from_secs(5)); } clock.advance(Duration::from_secs(15) - Duration::from_nanos(1)); @@ -830,6 +835,7 @@ mod tests { // instant, and the client's transport can break before the close code lands. client.closed().await; client.abort(moq_net::Error::Cancel); + adding.abort(); } /// Present `token` in the background, as a peer whose credit allows it. @@ -873,7 +879,8 @@ mod tests { } /// A waiting token whose admission times out is refused without reaching the - /// decider, while the check it waited behind keeps its slot past the timeout. + /// decider, while the check it waited behind keeps its slot past the timeout. The + /// refusal may pass, so the client keeps presenting both tokens. #[tokio::test(start_paused = true)] async fn a_waiting_token_times_out_without_reaching_the_decider() { let (lease, mut admissions) = admission_with(grant("a/**", 3000), 1).await; @@ -881,20 +888,23 @@ mod tests { let slow = present(&client, "slow"); let held = admissions.next().await.unwrap(); let waiting = present(&client, "waiting"); - assert!(matches!( - waiting.await.unwrap(), - Err(moq_net::Error::Session(SessionError::Internal)) - )); - assert!(matches!( - slow.await.unwrap(), - Err(moq_net::Error::Session(SessionError::Internal)) - )); + // Past the ten-second admission timeout, and the retries that follow it. + tokio::time::sleep(Duration::from_secs(30)).await; + assert!( + !waiting.is_finished() && !slow.is_finished(), + "a transient refusal ended a token" + ); assert!( admissions.next().now_or_never().is_none(), "a timed-out check released its slot" ); - // The decider giving up frees the slot for the next token, not the expired one. + // Withdrawn, the retried tokens leave the queue. The decider giving up frees the + // slot for the next token, not the expired one. + slow.abort(); + waiting.abort(); + let _ = slow.await; + let _ = waiting.await; drop(held); let _next = present(&client, "next"); let admission = admissions.next().await.unwrap(); diff --git a/rs/moq-tokio/MIGRATE.md b/rs/moq-tokio/MIGRATE.md index e76df241415..b2681f33cdc 100644 --- a/rs/moq-tokio/MIGRATE.md +++ b/rs/moq-tokio/MIGRATE.md @@ -4,6 +4,13 @@ How to update your code for each breaking change, newest first. ## Unreleased +### `Connection::auth()` returns the `moq_net` handle (#6109) + +`Connection::auth()` returns a `moq_net::auth::Handle` that every session of the connection is built on, and the `connection::auth` module (`Handle`, `Auth`, `Watch`) is removed. +Replace `moq_tokio::connection::auth::{Handle, Auth, Watch}` with `moq_net::auth::{Handle, Auth, Watch}`. +A token it returns can now be named by a `Route`, and keeps riding that route's announcements on every reconnect. +Its grant reads `None` while disconnected. A transiently refused token retries on a fixed backoff of 1s doubling to 30s rather than the reconnect `backoff`. + ### `jemalloc::tikv_jemallocator` is 0.7 (#6053) The re-exported allocator crate moved from tikv-jemallocator 0.6 to 0.7. diff --git a/rs/moq-tokio/src/connection.rs b/rs/moq-tokio/src/connection.rs index 3be71111588..104f7a889d2 100644 --- a/rs/moq-tokio/src/connection.rs +++ b/rs/moq-tokio/src/connection.rs @@ -2420,38 +2420,43 @@ mod tests { config.version = vec![version]; config.backoff.initial = Some(MIN_BACKOFF); config.backoff.max = Some(MIN_BACKOFF); - let connection = config.init(Default::default()).unwrap().connect(url(&format!("tcp://{addr}/"))); + let connection = config + .init(Default::default()) + .unwrap() + .connect(url(&format!("tcp://{addr}/"))); let auth = connection.auth(); let added = tokio::spawn({ let auth = auth.clone(); async move { auth.add("token").await } }); + // Held past each session's abort: dropping a grant closes its stream cleanly, which + // is the peer's verdict ending the token. + let mut issued = Vec::new(); + let mut added = Some(added); let mut token = None; for round in 0..2 { let request = tokio::time::timeout(WAIT, listener.accept()).await.unwrap().unwrap(); let mut requests = request.auth().requests().unwrap(); let (session, _task) = request.ok().await.unwrap(); - let _issued = loop { + loop { let request = tokio::time::timeout(WAIT, requests.next()).await.unwrap().unwrap(); - match request.token().as_ref() { - b"" => std::mem::forget(request.accept(moq_net::auth::Grant::default())), - token => { - assert_eq!(token, b"token", "round {round}"); - break request.accept(moq_net::auth::Grant::all()); - } + if request.token().is_empty() { + issued.push(request.accept(moq_net::auth::Grant::default())); + continue; } - }; - let token = match token.take() { - Some(token) => token, - None => added.await.unwrap().expect("granted"), - }; - let mut grant = token.grant(); + assert_eq!(request.token().as_ref(), b"token", "round {round}"); + issued.push(request.accept(moq_net::auth::Grant::all())); + break; + } + if let Some(added) = added.take() { + token = Some(added.await.unwrap().expect("granted")); + } + let mut grant = token.as_ref().unwrap().grant(); while grant.peek() != Some(moq_net::auth::Grant::all()) { tokio::time::timeout(WAIT, grant.changed()).await.unwrap().unwrap(); } session.abort(moq_net::Error::Cancel); - token = Some(token); } drop(connection); let token = token.unwrap(); From 1089bbcd19e3ac0b4235b227ead4cefd39f176e6 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 20 Oct 2026 17:07:01 -0700 Subject: [PATCH 06/45] feat(js/net): an auth handle that outlives its session Mirror the moq-net change in js/net. `Auth.Handle` is now a class: a session-independent handle passed to `connect({ auth })` presents every token it holds on each session built on it, so a route naming one of its `Auth`s keeps it across a reconnect. A handle backs one active session at a time, plus the ones draining after a GOAWAY; attaching another rejects with `Auth.Duplicate`. Only the peer's verdict, or the handle's end, ends a token. The reconnect loop owns one handle (`Reload.auth`), surfaced as `Connection.auth`, and aborts it when the loop closes. A transiently refused token on moq-lite is presented again after a 1s backoff doubling to 30s, as in Rust. `Auth.None` is removed. Update the docs that said to add the token again and re-announce after a reconnect, and add a MIGRATE entry. Co-Authored-By: Claude Opus 5.5 --- doc/bin/relay/auth.md | 8 +- doc/concept/moq-lite.md | 6 +- doc/concept/standard.md | 2 +- doc/lib/js/net.md | 2 +- doc/lib/rs/moq-net.md | 2 +- js/net/MIGRATE.md | 10 + js/net/bench/forward.ts | 7 +- js/net/src/auth.test.ts | 158 ++++++- js/net/src/auth.ts | 88 +--- js/net/src/auth_session.ts | 630 +++++++++++++++++++------- js/net/src/connection/connect.ts | 17 + js/net/src/connection/established.ts | 3 + js/net/src/connection/forward.test.ts | 7 +- js/net/src/connection/migrate.test.ts | 61 +++ js/net/src/connection/pool.ts | 14 + js/net/src/connection/reload.ts | 12 + js/net/src/ietf/connection.ts | 17 +- js/net/src/lite/connection.ts | 18 +- js/net/src/wire.ts | 4 + 19 files changed, 817 insertions(+), 249 deletions(-) diff --git a/doc/bin/relay/auth.md b/doc/bin/relay/auth.md index 25d59388048..03dd8ab3627 100644 --- a/doc/bin/relay/auth.md +++ b/doc/bin/relay/auth.md @@ -133,8 +133,8 @@ key can itself be **scoped** at generation (`--root`, `--publish`, On moq-lite-07-wip or moq-transport draft-17 and later with MoQ Auth negotiated, a client can present another JWT without reconnecting. Present its replacement before the current grant expires and keep the returned token handle alive. -The native `Connection::auth()` handle re-presents retained tokens after a -reconnect; the JavaScript session's `auth.add()` presents one for that session. +The auth handle a reconnecting connection owns (native `Connection::auth()`, +JavaScript `connection.auth`) presents its tokens again on every reconnect. Older versions refuse in-band tokens. Each accepted token adds its publish and subscribe patterns to the session's @@ -166,8 +166,8 @@ one that times out while waiting is refused without being sent. A sent check always runs to completion, so the connection's `end` stays its last request and `--limit-token` counts stay exact. On moq-lite, an auth server that cannot answer, by timeout or outage, refuses a token as a -transient error, and `Connection::auth()` presents it again on its reconnect -backoff. On moq-transport the refusal is final and the token ends. Retrying +transient error, and the client presents it again after a backoff of 1s +doubling to 30s. On moq-transport the refusal is final and the token ends. Retrying never extends an accepted grant, so a session whose last grant expires during an outage still closes. diff --git a/doc/concept/moq-lite.md b/doc/concept/moq-lite.md index edda6e031b0..5c1212d4e58 100644 --- a/doc/concept/moq-lite.md +++ b/doc/concept/moq-lite.md @@ -209,8 +209,10 @@ 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. Naming a fresh token renews the announcement in place with `REQUEST_UPDATE` from draft 16, and re-announces it on drafts 14 and 15, so it -can outlive the token it started with. 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 +can outlive the token it started with. A token added to a reconnecting +connection's auth handle is presented on every session it makes, so the +announcement keeps it across a reconnect with no re-add or re-announce; one +added to a single session's handle ends with that session. moq-lite has no per-request token, so there an announcement rides the session's scope. ## Subscriptions diff --git a/doc/concept/standard.md b/doc/concept/standard.md index 59605edbf21..8808362e43c 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 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 16, 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. +- **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 16, or ends and re-sends it before; the inline `NAMESPACE` that answers a solicited `SUBSCRIBE_NAMESPACE` from draft 16 carries no token. A token added to a reconnecting connection's auth handle rides every session it makes, so the announcement keeps it across a reconnect; one added to a single session's handle ends with that session. 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, negotiated subscriber max age, and delivery timeouts; Rust also renews request tokens and judges `NEW_GROUP_REQUEST` as a join (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, including across a moq-lite-07 hop between two relays that negotiate the [ietf](/draft/moq-ietf) extension. 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. On a timed track the frame holds each object's time, so a mutable Timestamp that only repeats the immutable one is not restated; a status object has no frame, so it keeps its Timestamp among its properties. 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 track advertises `DYNAMIC_GROUPS` when its publisher sets a refresh ratio, and a track received with `DYNAMIC_GROUPS` gets a ratio of 0. A `NEW_GROUP_REQUEST` (draft 15 and later) counts as one more subscriber joining, which a `SUBSCRIBE` already is: it asks for a new group only when the newest group's bytes after its first frame exceed the ratio times the first frame. Joins coalesce until the asked-for group arrives. A Rust relay sends `NEW_GROUP_REQUEST` upstream, on the `SUBSCRIBE` or in a `REQUEST_UPDATE`, once a join wants a group past the newest it holds; a relay holding nothing yet asks for none. On a track without a ratio, an update carrying one ends the subscription with `UPDATE_FAILED`. 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. A moq-lite-07 hop (`moq-lite-07-wip`, opt-in) carries the same demand natively: the ratio in `TRACK_INFO` and the asked-for group in `SUBSCRIBE_UPDATE`. Earlier moq-lite versions carry neither, so a track received over one never refreshes. diff --git a/doc/lib/js/net.md b/doc/lib/js/net.md index b0c833901ec..133266f5362 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 (opt in with `versions: ["moq-lite-07-wip", "moq-lite-06"]`, listing every fallback you still want) 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. +- **Authorization is in band.** On moq-lite 07 (opt in with `versions: ["moq-lite-07-wip", "moq-lite-06"]`, listing every fallback you still want) 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. A `Connection`'s `auth` handle outlives its sessions: a token added there is presented on every reconnect and keeps riding the announcements that name it, with no re-add or re-announce. A one-shot session's `auth` ends with it, unless `Connection.connect` was given a `new Auth.Handle()` as `auth`. - **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/doc/lib/rs/moq-net.md b/doc/lib/rs/moq-net.md index 1763f6251d2..b0358c3b135 100644 --- a/doc/lib/rs/moq-net.md +++ b/doc/lib/rs/moq-net.md @@ -25,7 +25,7 @@ What you use it for, beyond what the concept page already describes: - **Use the model without a runtime.** Disable default features on `moq-net` to use its `poll_*` APIs and origin driver with caller-supplied time. The default `async` feature adds async helpers, `time::run`, and both wire sessions. - **You drive the session, or `moq-tokio` does.** `connect` and `accept` return a session plus a driver that never reads the clock itself. `moq_net::time::run` polls it on tokio or in the browser. `moq-tokio` and `moq-wasm` hide that. A custom transport implements `moq_net::transport::poll`, and `moq_net::transport::memory::pair` links two sessions in one process with no sockets, for tests or in-process links. - **A session caps what its peer can hold.** `session::Limits` (via `Client::with_limits` and `Server::with_limits`) bounds announces, subscriptions, and FETCH requests independently per session; the defaults suit a relay mesh, so lower them for untrusted peers. Past a cap, the session closes with `TOO_MANY_REQUESTS`. On moq-transport drafts 14 to 16 they also size the request-ID window, which every request counts against, so very low limits can starve it. Peer-declared lengths are capped before they are buffered. -- **Authorization is in band.** On moq-lite 07 (`moq-lite-07-wip`, opt-in) and on moq-transport draft-17+ with the [MoQ Auth extension](/draft/moq-auth), `session.auth()` presents tokens without reconnecting and watches the grant the peer sent back. A server verifies tokens itself by taking `handshake.auth().requests()` before `ok()`. `authorize(&grant)` narrows or widens a live session on any version: what falls outside resets with `Unauthorized` and the session stays up. A client that publishes outside its grant closes the session, naming the path. +- **Authorization is in band.** On moq-lite 07 (`moq-lite-07-wip`, opt-in) and on moq-transport draft-17+ with the [MoQ Auth extension](/draft/moq-auth), `session.auth()` presents tokens without reconnecting and watches the grant the peer sent back. A handle passed to `Client::with_auth` outlives each session, so its tokens, and the routes naming them, carry across your own reconnects. A server verifies tokens itself by taking `handshake.auth().requests()` before `ok()`. `authorize(&grant)` narrows or widens a live session on any version: what falls outside resets with `Unauthorized` and the session stays up. A client that publishes outside its grant closes the session, naming the path. - **A graceful close waits.** `session.close().await` withdraws announcements and drains for up to one second. Application delivery requires explicit `moq-lite-07-wip`; older Lite versions (including default lite-06) and IETF wait only for transport ACKs. `abort` ends immediately. IETF drafts 14 through 16 send withdrawals without waiting. ```bash diff --git a/js/net/MIGRATE.md b/js/net/MIGRATE.md index 4d7076305f6..f2908310a1b 100644 --- a/js/net/MIGRATE.md +++ b/js/net/MIGRATE.md @@ -4,6 +4,16 @@ How to update your code for each breaking change, newest first. ## Unreleased +### An auth handle can outlive its session (#6109) + +`Auth.Handle` is now a class: `new Auth.Handle()` backs no session yet, and `connect({ auth })` builds the session on it, so every session built on one presents the tokens it holds. +A reconnecting `Connection` owns one as `connection.auth`; a route naming one of its `Auth.Auth`s keeps that token across a reconnect, so drop the code that added the token again and re-announced after one. +A handle backs one active session at a time, plus the ones draining after a GOAWAY: connecting another while one is still active rejects with `Auth.Duplicate`. +`Established.auth` returns the session's handle, so its `grant` and each `Auth.Auth`'s `grant` read undefined once the session closes, instead of an empty grant. +`Auth.Auth.closed` settles only on the peer's verdict or the handle's end (`handle.abort()`); a session ending is not one, except for a handle the session created for itself. +`Auth.None` is removed; nothing returned it. +On moq-lite, a token refused with a code that may pass (`Internal`, `TooManyRequests`, `Timeout`) is presented again after a backoff of 1s doubling to 30s, instead of `add` rejecting with that code. + ### `largest()` reports within a reader's `setGroups` bounds `Track.Subscriber.largest()` and `Track.Ordered.largest()` report the track's largest only while it is within the bounds set with `setGroups` or `withGroups`, where they used to ignore the bounds. diff --git a/js/net/bench/forward.ts b/js/net/bench/forward.ts index ba8e2a6fb4a..f174a35548a 100644 --- a/js/net/bench/forward.ts +++ b/js/net/bench/forward.ts @@ -7,6 +7,7 @@ import { Manual } from "@moq/clock"; import { Once } from "@moq/signals"; import { announcedView } from "../src/announced.test-util"; import * as Announce from "../src/announced.ts"; +import { AuthSession } from "../src/auth_session.ts"; import { Producer as BroadcastProducer } from "../src/broadcast.ts"; import type { Established } from "../src/connection/established.ts"; import { forwardAnnounced } from "../src/connection/forward.ts"; @@ -32,7 +33,11 @@ class FakeSession { die!: () => void; constructor() { - registerWire(this, { consume: () => new BroadcastProducer().consume(), goaway: new Once() }); + registerWire(this, { + consume: () => new BroadcastProducer().consume(), + goaway: new Once(), + auth: new AuthSession({ peerGrant: { publish: new Path.Patterns(), subscribe: new Path.Patterns() } }), + }); this.closed = new Promise((resolve) => { this.die = () => resolve(null); }); diff --git a/js/net/src/auth.test.ts b/js/net/src/auth.test.ts index 27a50d510b2..ae5295e9e05 100644 --- a/js/net/src/auth.test.ts +++ b/js/net/src/auth.test.ts @@ -1,8 +1,7 @@ import { describe, expect, spyOn, test } from "bun:test"; import { Manual, 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 { Duplicate, type Grant, Handle, type Issued, type Request, type Requests, Unsupported } from "./auth.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"; @@ -67,17 +66,93 @@ interface Pair { transport: MockTransport; } -async function connect(opts: { publish?: OriginProducer; serverPublish?: OriginProducer; protocol: string }) { +async function connect(opts: { + publish?: OriginProducer; + serverPublish?: OriginProducer; + protocol: string; + auth?: Handle; +}) { const pair = createMockTransportPair(opts.protocol); const [client, server] = await Promise.all([ - connectSession({ url, transport: pair.client, publish: opts.publish?.consume() }), + connectSession({ url, transport: pair.client, publish: opts.publish?.consume(), auth: opts.auth }), acceptSession({ transport: pair.server, url, publish: opts.serverPublish?.consume() }), ]); return { client, server, transport: pair.client } satisfies Pair; } +/** Answer every token the peer presents with `answer`'s grant, holding each until the session ends. */ +function answer(requests: Requests, answer: (token: string) => Grant): Issued[] { + const issued: Issued[] = []; + void (async () => { + for (;;) { + const request = await requests.next(); + if (!request) break; + issued.push(request.accept(answer(new TextDecoder().decode(request.token)))); + } + })(); + return issued; +} + // Every case runs on moq-lite-07-wip and on moq-transport with the MoQ Auth extension. describe.each([Lite.ALPN_07_WIP, Ietf.ALPN.DRAFT_17, Ietf.ALPN.DRAFT_22])("%s", (protocol) => { + test("a shared handle presents its token on every session", async () => { + const handle = new Handle(); + // Added before any session: it waits for the first. + const adding = handle.add("token"); + + const first = await connect({ publish: new OriginProducer(), protocol, auth: handle }); + answer(first.server.auth.requests(), (token) => (token ? grant(["first"], []) : grant([], []))); + const token = await adding; + await waitFor(token.grant, (g) => g?.publish.equals(patterns("first")) === true); + expect(first.client.auth).toBe(handle); + + first.client.abort(); + first.server.abort(); + expect(token.grant.peek()).toBeUndefined(); + expect(handle.grant.peek()).toBeUndefined(); + let ended = false; + void token.closed.then(() => { + ended = true; + }); + await nextTask(); + expect(ended).toBe(false); + + const second = await connect({ publish: new OriginProducer(), protocol, auth: handle }); + answer(second.server.auth.requests(), (token) => (token ? grant(["second"], []) : grant([], []))); + await waitFor(token.grant, (g) => g?.publish.equals(patterns("second")) === true); + await waitFor(handle.grant, (g) => g?.publish.equals(patterns("second")) === true); + expect(ended).toBe(false); + // A route naming the token carries it on the new session's requests, on moq-transport. + const carried = wireOf(second.client).auth.carried(token); + if (protocol === Lite.ALPN_07_WIP) expect(carried).toBeUndefined(); + else expect(carried).toEqual(new TextEncoder().encode("token")); + + token.close(); + expect(await token.closed).toBeNull(); + await waitFor(handle.grant, (g) => g?.publish.equals(patterns("")) === false); + second.client.abort(); + second.server.abort(); + }); + + test("a handle backs one active session", async () => { + const handle = new Handle(); + const first = await connect({ publish: new OriginProducer(), protocol, auth: handle }); + const pair = createMockTransportPair(protocol); + await expect(connectSession({ url, transport: pair.client, auth: handle })).rejects.toBeInstanceOf(Duplicate); + pair.client.close(); + + first.client.abort(); + first.server.abort(); + const second = await connect({ publish: new OriginProducer(), protocol, auth: handle }); + expect(second.client.auth).toBe(handle); + + // Ending the handle ends the tokens it holds, and no session can be built on it. + handle.abort(new Error("done")); + await expect(handle.add("late")).rejects.toThrow("done"); + second.client.abort(); + second.server.abort(); + }); + test("both sides learn their default grant", async () => { const { client, server } = await connect({ publish: new OriginProducer(), protocol }); @@ -279,11 +354,13 @@ describe.each([Lite.ALPN_07_WIP, Ietf.ALPN.DRAFT_17, Ietf.ALPN.DRAFT_22])("%s", // Checked before any stream notices the transport closing. client.abort(); - const closed = client.auth.grant.peek(); + // No session is live, and every token ended with it. + expect(client.auth.grant.peek()).toBeUndefined(); + const closed = wireOf(client).auth.grant.peek(); expect(closed?.publish.size).toBe(0); expect(closed?.subscribe.size).toBe(0); // What the session enforced is not revoked by its end. - expect((client.auth as AuthSession).scope.peek()?.publish.size).toBeGreaterThan(0); + expect(wireOf(client).auth.scope.peek()?.publish.size).toBeGreaterThan(0); server.abort(); }); @@ -298,7 +375,7 @@ describe.each([Lite.ALPN_07_WIP, Ietf.ALPN.DRAFT_17, Ietf.ALPN.DRAFT_22])("%s", published.appendGroup().writeFrame({ payload: new Uint8Array([1]) }); const { client, server } = await connect({ serverPublish: origin, protocol }); const requests = server.auth.requests(); - await (client.auth as AuthSession).ready(Path.from("room/x"), "subscribe"); + await wireOf(client).auth.ready(Path.from("room/x"), "subscribe"); const setup = await requests.next(); if (!setup) throw new Error("missing setup token"); const initial = setup.accept(grant([], [])); @@ -361,7 +438,7 @@ describe.each([Lite.ALPN_07_WIP, Ietf.ALPN.DRAFT_17, Ietf.ALPN.DRAFT_22])("%s", if (!unanswered) throw new Error("missing token"); const track = new OriginProducer().createBroadcast(Path.from("room/x")).createTrack("video"); const reader = track.subscribe(); - const ready = (client.auth as AuthSession).ready(Path.from("room/x"), "subscribe", { + const ready = wireOf(client).auth.ready(Path.from("room/x"), "subscribe", { until: () => abandoned(track.demand()), }); let settled = false; @@ -472,8 +549,8 @@ describe.each([Lite.ALPN_07_WIP, Ietf.ALPN.DRAFT_17, Ietf.ALPN.DRAFT_22])("%s", // The grant drops as the session closes, which can land before the transport's close // reaches the subscription; force that order. - (client.auth as AuthSession).close(); - expect(client.auth.grant.peek()?.subscribe.size).toBe(0); + wireOf(client).auth.close(); + expect(wireOf(client).auth.grant.peek()?.subscribe.size).toBe(0); await nextTask(); expect(track.closed.peek()).toBeUndefined(); @@ -579,6 +656,65 @@ describe.each([Ietf.ALPN.DRAFT_17, Ietf.ALPN.DRAFT_22])("%s", (protocol) => { }); }); +// moq-transport's AUTH_ERROR carries no code that may pass, so only lite retries. +test("lite-07 presents a transiently refused token again after a backoff", async () => { + const clock = new Manual(); + const restore = clock.install(); + const warned = spyOn(console, "warn").mockImplementation(() => {}); + const refusals = () => warned.mock.calls.filter(([line]) => line === "auth token refused").length; + const { client, server } = await connect({ publish: new OriginProducer(), protocol: Lite.ALPN_07_WIP }); + const requests = server.auth.requests(); + const issued: Issued[] = []; + let added = false; + + /** Refuse `request` as busy, once the client has started its backoff. */ + const refuse = async (request: Request | undefined) => { + expect(request && new TextDecoder().decode(request.token)).toBe("token"); + const before = refusals(); + request?.reject(SessionCode.TooManyRequests, "busy"); + // The client starts its backoff as it logs the refusal. + await settles(() => refusals() > before); + }; + + /** The token presented again, exactly once `backoff` passes. */ + const after = async (backoff: Milli) => { + let presented = false; + const next = requests.next().then((request) => { + presented = true; + return request; + }); + clock.advance(Nano.fromMilli(Milli(backoff - 1))); + for (let i = 0; i < 10; i++) await nextTask(); + expect(presented).toBe(false); + expect(added).toBe(false); + clock.advance(Nano.fromMilli(Milli(1))); + return await next; + }; + + try { + const setup = await requests.next(); + if (!setup) throw new Error("missing setup token"); + issued.push(setup.accept(grant([], []))); + + const adding = client.auth.add("token"); + void adding.then(() => { + added = true; + }); + await refuse(await requests.next()); + await refuse(await after(Milli(1000))); + const accepted = await after(Milli(2000)); + if (!accepted) throw new Error("the token was not presented again"); + issued.push(accepted.accept(grant(["a"], []))); + const token = await adding; + expect(token.grant.peek()?.publish.equals(patterns("a"))).toBe(true); + } finally { + client.abort(); + server.abort(); + warned.mockRestore(); + restore(); + } +}); + // moq-lite carries patterns, so every grant arrives exactly as issued. test("a fetch abandoned while its token waits never reaches the wire", async () => { const logged = spyOn(console, "debug"); @@ -717,7 +853,7 @@ test.each([ 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")); + expect(wireOf(client).auth.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.ts b/js/net/src/auth.ts index 6a191369d5d..5d2189cc92d 100644 --- a/js/net/src/auth.ts +++ b/js/net/src/auth.ts @@ -8,16 +8,22 @@ * 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 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`. + * presents another without reconnecting, returning its {@link Auth}. + * + * A {@link Handle} passed to `connect` as `auth`, or the one a reconnecting `Connection` owns, + * outlives its sessions: each presents every token it holds, so a route naming one of its + * {@link Auth}s keeps it across a reconnect. Only the peer's verdict ends such a token; a + * session ending leaves it for the next. Mirrors the Rust `moq_net::auth`. * * @module */ -import { type Getter, Signal } from "@moq/signals"; -import { SessionCode } from "./error.ts"; +import type { Getter } from "@moq/signals"; +import type { SessionCode } from "./error.ts"; import type * as Path from "./path.ts"; +export { Duplicate, grantsEqual, Handle, Unsupported } from "./auth_session.ts"; + /** * What a peer lets this side do, in this side's own paths (relative to the session). * @@ -32,27 +38,21 @@ export interface Grant { expires?: number; } -/** Whether two grants allow the same paths and lapse together. */ -export function grantsEqual(a: Grant | undefined, b: Grant | undefined): boolean { - if (a === undefined || b === undefined) return a === b; - return a.publish.equals(b.publish) && a.subscribe.equals(b.subscribe) && a.expires === b.expires; -} - -/** The session or peer carries no AUTH exchange, or takes no tokens in band. */ -export class Unsupported extends Error { - /** The moq-lite session code for this condition. */ - readonly code = SessionCode.Unsupported; - constructor(message = "auth unsupported") { - super(message); - this.name = "Unsupported"; - } -} - -/** A token this side presented, which a route can name. {@link Auth.close} withdraws it. */ +/** + * A token this side presented, which a route can name, on every session of its handle. + * {@link Auth.close} withdraws it. + */ export interface Auth { - /** This token's own grant: undefined until the peer answers, and again once it ends. */ + /** + * This token's own grant on the live session: undefined until its peer answers, while no + * session is live, and again once it ends. + */ readonly grant: Getter; - /** Settles when the token ends: the peer's revocation, or its withdrawal. */ + /** + * Settles when the token ends for good: the peer's refusal or revocation, `null` when the + * peer closed it without one or it was withdrawn, or the handle's end. A session ending is + * not one: the next session presents it again. + */ readonly closed: PromiseLike; /** Withdraw the token, removing its grant from the union and remaking any request that carries it without it. */ close(): void; @@ -94,45 +94,3 @@ export interface Requests { /** Refuse every later token. */ close(): void; } - -/** A session's tokens and grants. See the module docs. */ -export interface Handle { - /** The union of every grant this side holds: undefined until the peer first answers. */ - readonly grant: Getter; - - /** - * 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`. 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; - - /** - * Answer every token the peer presents, instead of the default: granting what this side - * publishes and consumes for the connection's own credential and refusing any other. - * - * Call it as soon as connect or accept resolves, in the same task: whoever answers is - * decided once, right after. Throws once that has happened. - * - * The peer's stream credit bounds how many tokens wait unanswered, since each holds its - * stream open until answered or withdrawn. A withdrawn token leaves the queue. - */ - requests(): Requests; -} - -/** The {@link Handle} of a session without AUTH: no grant, and no tokens. */ -export class None implements Handle { - /** Always undefined: no token is ever granted. */ - readonly grant: Getter = new Signal(undefined); - - /** Always rejects with {@link Unsupported}. */ - add(_token: string | Uint8Array): Promise { - return Promise.reject(new Unsupported()); - } - - /** A stream that never yields a request. */ - requests(): Requests { - return { next: async () => undefined, close: () => {} }; - } -} diff --git a/js/net/src/auth_session.ts b/js/net/src/auth_session.ts index dc326b35b9c..af77848b076 100644 --- a/js/net/src/auth_session.ts +++ b/js/net/src/auth_session.ts @@ -1,20 +1,51 @@ -import { Instant, Nano, now, wall } from "@moq/clock"; +import { Instant, Milli, Nano, now, sleep, wall } from "@moq/clock"; import { type Dispose, type Getter, Once, Signal } from "@moq/signals"; -import { - type Auth, - type Grant, - grantsEqual, - type Handle, - type Issued, - type Request, - type Requests, - Unsupported, -} from "./auth.ts"; +import type { Auth, Grant, Issued, Request, Requests } from "./auth.ts"; import { closeReason, error, ProtocolViolation, SessionCode, SessionError, StreamCode, StreamError } from "./error.ts"; import * as Path from "./path.ts"; import type { Stream } from "./stream.ts"; import { nextTask } from "./util/yield.ts"; +/** Whether two grants allow the same paths and lapse together. */ +export function grantsEqual(a: Grant | undefined, b: Grant | undefined): boolean { + if (a === undefined || b === undefined) return a === b; + return a.publish.equals(b.publish) && a.subscribe.equals(b.subscribe) && a.expires === b.expires; +} + +/** The session or peer carries no AUTH exchange, or takes no tokens in band. */ +export class Unsupported extends Error { + /** The moq-lite session code for this condition. */ + readonly code = SessionCode.Unsupported; + constructor(message = "auth unsupported") { + super(message); + this.name = "Unsupported"; + } +} + +/** A handle already backs a session that is open and not draining. */ +export class Duplicate extends Error { + constructor(message = "auth handle already backs an active session") { + super(message); + this.name = "Duplicate"; + } +} + +// How long a token refused for a reason that may pass waits before its session presents it +// again, doubling on each refusal up to RETRY_MAX. +const RETRY_INITIAL = Milli(1000); +const RETRY_MAX = Milli(30_000); + +// Whether a refusal is the acceptor failing to decide rather than its verdict on the token, +// such as an unreachable auth server or a full verification queue. +function transient(err: Error | null): boolean { + return ( + err instanceof SessionError && + (err.code === SessionCode.Internal || + err.code === SessionCode.TooManyRequests || + err.code === SessionCode.Timeout) + ); +} + /** A grant as the wire carries it: the expiry is milliseconds from now. @internal */ export interface WireGrant { publish: Path.Patterns; @@ -69,27 +100,48 @@ function transportClosed(err: unknown): boolean { function cancel(): StreamError { return new StreamError(StreamCode.Cancel, { message: "cancel" }); } +/** One token as one session presents it. */ +class Presented { + readonly token: Uint8Array; + /** The session's own credential, presented at setup on this session alone. */ + readonly setup: boolean; + /** The handle's token this presents, or undefined for the session's own credential. */ + readonly owner?: Token; + + /** The latest grant: undefined before the first, during a retry, and once it ends here. */ + grant?: Grant; + stream?: Stream; + /** Wakes a retry backoff early once the presentation is no longer wanted. */ + retry?: AbortController; + withdrawn = false; + isAnswered = false; + ended = false; + + constructor(token: Uint8Array, setup: boolean, owner?: Token) { + this.token = token; + this.setup = setup; + this.owner = owner; + } +} -/** One token this side presented. */ -class Presented implements Auth { +/** A token the application added to a {@link Handle}, presented on every session of it. */ +class Token implements Auth { readonly grant = new Signal(undefined); readonly closed: Promise; readonly answered: Promise; - readonly setup: boolean; - readonly token: Uint8Array; + readonly bytes: Uint8Array; - stream?: Stream; - withdrawn = false; isAnswered = false; ended = false; + #withdraw: (token: Token) => void; #close!: (err: Error | null) => void; #answer!: () => void; #refuse!: (err: Error) => void; - constructor(token: Uint8Array, setup: boolean) { - this.token = token; - this.setup = setup; + constructor(bytes: Uint8Array, withdraw: (token: Token) => void) { + this.bytes = bytes; + this.#withdraw = withdraw; this.closed = new Promise((resolve) => { this.#close = resolve; }); @@ -119,13 +171,188 @@ class Presented implements Auth { } close() { - if (this.withdrawn) return; - this.withdrawn = true; - // The stream's loop notices and ends the token; one still opening checks on arrival. - // 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); + this.#withdraw(this); + } +} + +/** The state behind one {@link Handle}: the tokens it presents, and every session it backs. */ +class HandleState { + /** The live session's union. */ + readonly grant = new Signal(undefined); + /** Created by a session for itself, so it ends with that session. */ + own = false; + /** Set once the handle ends. */ + closed?: Error; + /** Every token the application added that has not ended. */ + readonly tokens = new Set(); + /** Every open session built on this handle, oldest first. */ + readonly sessions: AuthSession[] = []; + acceptor: "undecided" | "default" | RequestQueue = "undecided"; + + /** The session the handle backs now: the newest one still open. */ + get live(): AuthSession | undefined { + return this.sessions.at(-1); + } + + /** Fail unless a new session can be built on the handle. */ + vacant() { + if (this.closed) throw this.closed; + if (this.sessions.some((session) => session.active)) throw new Duplicate(); + } + + attach(session: AuthSession) { + this.vacant(); + this.sessions.push(session); + } + + /** A session closed: it presents nothing anymore. A handle of its own ends with it. */ + detach(session: AuthSession, err: Error) { + const index = this.sessions.indexOf(session); + if (index >= 0) this.sessions.splice(index, 1); + if (this.own) this.abort(err); + this.refresh(); + } + + /** Point the handle's grant, and each token's, at the live session. */ + refresh() { + const live = this.live; + const union = live?.grant.peek(); + if (!grantsEqual(union, this.grant.peek())) this.grant.set(union); + for (const token of this.tokens) { + const grant = live?.grantOf(token); + if (!grantsEqual(grant, token.grant.peek())) token.grant.set(grant); + } + } + + add(bytes: Uint8Array): Token { + if (this.closed) throw this.closed; + const token = new Token(bytes, (token) => this.withdraw(token)); + this.tokens.add(token); + for (const session of [...this.sessions]) session.offer(token); + return token; + } + + /** The application dropped the token: every session stops presenting it. */ + withdraw(token: Token) { + if (!this.tokens.delete(token)) return; + for (const session of this.sessions) session.withdraw(token); + token.end(null); + } + + /** The peer's verdict, or the handle's end: the token ends on every session. */ + verdict(token: Token, err: Error | null) { + if (!this.tokens.delete(token)) return; + token.end(err); + for (const session of this.sessions) session.withdraw(token); } + + /** Decide who answers the peer's tokens, once: the application if it took them. */ + decide(): RequestQueue | undefined { + if (this.acceptor === "undecided") this.acceptor = "default"; + return this.acceptor instanceof RequestQueue ? this.acceptor : undefined; + } + + abort(err: Error) { + if (this.closed) return; + this.closed = err; + for (const token of [...this.tokens]) this.verdict(token, err); + if (this.acceptor instanceof RequestQueue) this.acceptor.close(); + } +} + +const states = new WeakMap(); + +function stateOf(handle: Handle): HandleState { + const state = states.get(handle); + if (!state) throw new TypeError("not an auth handle"); + return state; +} + +/** + * Fail unless a session can be built on `handle`: it ended, or it backs an active session. + * + * @internal + */ +export function vacant(handle: Handle) { + stateOf(handle).vacant(); +} + +/** + * The tokens this side presents and the grant they earn, across every session built on it. + * + * A session made without one creates its own, returned by `Established.auth`, which ends with + * that session. Pass one to `connect` as `auth` instead and it outlives its sessions: each + * presents every token the handle holds, so an {@link Auth} it returned keeps riding a route's + * announcements across a reconnect. It backs one active session at a time, plus the ones + * draining after a GOAWAY. Mirrors the Rust `moq_net::auth::Handle`. + */ +export class Handle { + /** A handle backing no session yet, for `connect`'s `auth` option. */ + constructor() { + states.set(this, new HandleState()); + } + + /** + * The union of every grant this side holds on the live session: undefined until its peer + * first answers a token, while no session is live, and forever on a version without AUTH. + */ + get grant(): Getter { + return stateOf(this).grant; + } + + /** + * Present another token on every session of this handle, resolving once a peer answers it. + * + * With no session live it waits for the next one. A refusal that may pass, such as an + * unreachable auth server, is presented again after a backoff on moq-lite. Rejects with + * {@link Unsupported} when a session neither carries AUTH nor lets a request carry a token + * (moq-lite without AUTH), with the peer's refusal as an `Error.Session`, or with the + * handle's end. On moq-transport without AUTH it resolves at once with no grant: only a + * request whose route names it carries it. + */ + async add(token: string | Uint8Array): Promise { + // 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(); + const added = stateOf(this).add(bytes); + await added.answered; + return added; + } + + /** + * Answer every token the peer presents, instead of the default: granting what this side + * publishes and consumes for the connection's own credential and refusing any other. + * + * Call it as soon as connect or accept resolves, in the same task: whoever answers is + * decided once, right after. Throws once that has happened. + * + * The peer's stream credit bounds how many tokens wait unanswered, since each holds its + * stream open until answered or withdrawn. A withdrawn token leaves the queue. + */ + requests(): Requests { + const state = stateOf(this); + if (state.acceptor !== "undecided") throw new Error("auth requests already taken or answered by default"); + const queue = new RequestQueue(); + // A session of its own that carries no AUTH never receives a token. + if (state.closed || (state.own && !state.live?.negotiated)) queue.close(); + state.acceptor = queue; + return queue; + } + + /** + * End the handle with `err`: every token ends, its sessions stop presenting them, and the + * peer's queued tokens are refused. A handle a session created for itself ends with that + * session. + */ + abort(err: Error = new Error("auth handle closed")): void { + stateOf(this).abort(err); + } +} + +/** A handle of a session's own, which ends with it. */ +function ownHandle(): Handle { + const handle = new Handle(); + stateOf(handle).own = true; + return handle; } /** The peer's token, answered by the application. */ @@ -265,28 +492,35 @@ 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. + * Whether a request can carry a token (moq-transport), so a token the wire cannot present + * is kept for the announcements that name it. */ requests?: boolean; /** What the default acceptor grants the peer's connection credential. */ peerGrant: Grant; + /** The handle the session is built on, or undefined for one of its own that ends with it. */ + handle?: Handle; + /** Whether the session is draining after a GOAWAY, which frees its handle for a successor. */ + draining?: () => boolean; } /** - * A session's tokens and grants: presents ours, one stream each, and answers the peer's. - * The wire decides only the encoding; see {@link AuthWire}. + * One session's side of a {@link Handle}: presents the handle's tokens and its own + * credential, one stream each, and answers the peer's. The wire decides only the encoding; + * see {@link AuthWire}. * * @internal */ -export class AuthSession implements Handle { +export class AuthSession { /** The peer sent a malformed AUTH message; the owning connection closes the session. */ readonly violated = new Once(); + /** The handle this session is built on. */ + readonly handle: Handle; + #state: HandleState; #wire?: AuthWire; #requests: boolean; #peerGrant: Grant; - // Every token this session issued, so a request carries only its own. - #issued = new WeakSet(); + #draining: () => boolean; #ended = new Signal(0); #union = new Signal(undefined); @@ -295,10 +529,11 @@ export class AuthSession implements Handle { #scope = new Signal(undefined); // The peer replied to some token, so the union is known even when empty. #replied = false; - #tokens = new Set(); + #presented = new Set(); + // The handle's tokens as this session presents them, so a request carries only those. + #byToken = new Map(); #setupPending = new Signal(0); #pending = new Signal(0); - #acceptor: "undecided" | "default" | RequestQueue = "undecided"; #closed = false; // Whoever answers the peer's tokens is decided once, after the task that established @@ -306,16 +541,28 @@ export class AuthSession implements Handle { // wins, however quickly the peer's first token arrives. #decided = nextTask(); - constructor({ wire, requests, peerGrant }: AuthSessionProps) { + /** Throws {@link Duplicate} while `handle` backs another active session, or the handle's end. */ + constructor({ wire, requests, peerGrant, handle, draining }: AuthSessionProps) { + this.handle = handle ?? ownHandle(); + this.#state = stateOf(this.handle); this.#wire = wire; this.#requests = requests ?? false; this.#peerGrant = peerGrant; + this.#draining = draining ?? (() => false); + this.#state.attach(this); // Present the connection's own credential right away, so both sides learn their // grant without waiting on the app. - if (wire) this.#present(wire, new Uint8Array(), true); + if (wire) this.#present(wire, new Presented(new Uint8Array(), true)); + for (const token of [...this.#state.tokens]) this.offer(token); } + /** Whether the session still takes new work: open, with no GOAWAY received. */ + get active(): boolean { + return !this.#closed && !this.#draining(); + } + + /** The union of this session's grants, undefined until its peer first answers. */ get grant(): Getter { return this.#union; } @@ -330,30 +577,59 @@ export class AuthSession implements Handle { return this.#wire !== undefined; } - async add(token: string | Uint8Array): Promise { - if (this.#closed || (!this.#wire && !this.#requests)) throw new Unsupported(); - // 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); - presented.answer(); - this.#track(presented); - return presented; + /** The handle's `token` grant on this session. */ + grantOf(token: Token): Grant | undefined { + return this.#byToken.get(token)?.grant; + } + + /** Present one of the handle's tokens on this session. */ + offer(token: Token) { + if (this.#closed || this.#byToken.has(token)) return; + if (!this.#wire && !this.#requests) { + this.#state.verdict(token, new Unsupported()); + return; } - const presented = this.#present(this.#wire, bytes, false); - await presented.answered; - return presented; + const presented = new Presented(token.bytes, false, token); + this.#byToken.set(token, presented); + if (this.#wire) { + this.#present(this.#wire, presented); + return; + } + // Only a request carries it, and the peer judges it there: no grant to wait for. + presented.isAnswered = true; + this.#presented.add(presented); + token.answer(); + } + + /** Stop presenting one of the handle's tokens: withdrawn, or ended for good elsewhere. */ + withdraw(token: Token) { + const presented = this.#byToken.get(token); + if (!presented || presented.withdrawn) return; + presented.withdrawn = true; + presented.grant = undefined; + // The stream's loop notices and ends the presentation; one still opening checks on + // arrival, and one waiting to retry wakes. One without a stream to come (carried only by + // requests) ends here. + if (presented.stream) presented.stream.abort(cancel()); + else if (presented.retry) presented.retry.abort(); + else if (!this.#wire) { + presented.ended = true; + this.#presented.delete(presented); + this.#byToken.delete(token); + this.#ended.update((n) => n + 1); + } + this.#recompute(); } /** - * The bytes a request carries for `token`: undefined for none, for one another session - * issued, or for one that ended. + * The bytes a request carries for `token`: undefined for none, for one another handle + * issued, or for one that ended here. */ carried(token: Auth | undefined): Uint8Array | undefined { - if (!this.#requests || !(token instanceof Presented) || !this.#issued.has(token) || token.ended) - return undefined; - return token.token; + if (!this.#requests || !(token instanceof Token)) return undefined; + const presented = this.#byToken.get(token); + if (!presented || presented.ended || presented.withdrawn) return undefined; + return presented.token; } /** Bumps whenever one of this session's tokens ends, so a request carrying it drops it. */ @@ -361,14 +637,6 @@ export class AuthSession implements Handle { return this.#ended; } - requests(): Requests { - if (this.#acceptor !== "undecided") throw new Error("auth requests already taken or answered by default"); - const queue = new RequestQueue(); - if (!this.#wire) queue.close(); - this.#acceptor = queue; - return queue; - } - /** Resolves once every token the session presented at setup has its first reply. */ async setupAnswered(): Promise { while (this.#setupPending.peek() > 0) await this.#setupPending.changed(); @@ -450,12 +718,12 @@ export class AuthSession implements Handle { throw new ProtocolViolation("malformed AUTH", { cause: err }); } await this.#decided; - if (this.#acceptor === "undecided") this.#acceptor = "default"; + const acceptor = this.#state.decide(); const issued = new IssuedGrant(stream, wire); - if (this.#acceptor instanceof RequestQueue) { + if (acceptor) { const request = new PeerRequest(token, issued); - this.#acceptor.push(request); + acceptor.push(request); } else if (token.byteLength > 0) { // Only the connection's own credential has a default answer; a token needs // someone to verify it. @@ -470,130 +738,178 @@ export class AuthSession implements Handle { if (ended instanceof ProtocolViolation) throw ended; } - /** End the session: fail every pending token and close the requests. */ - close() { + /** + * End the session: fail every pending token here, leaving the handle's tokens for the next + * session. A handle of the session's own ends with it, closing the requests. + */ + close(err: Error = new Error("session closed")) { if (this.#closed) return; this.#closed = true; this.#pending.set(0); - for (const token of this.#tokens) token.end(new Error("session closed")); - this.#tokens.clear(); + for (const presented of this.#presented) { + presented.ended = true; + presented.isAnswered = true; + presented.grant = undefined; + presented.retry?.abort(); + } + this.#presented.clear(); + this.#byToken.clear(); this.#recompute(); - if (this.#acceptor instanceof RequestQueue) this.#acceptor.close(); + this.#state.detach(this, err); } - #present(wire: AuthWire, token: Uint8Array, setup: boolean): Presented { - const presented = new Presented(token, setup); - this.#track(presented); - if (setup) this.#setupPending.update((n) => n + 1); - if (token.byteLength > 0) this.#pending.update((n) => n + 1); + #present(wire: AuthWire, presented: Presented) { + this.#presented.add(presented); + if (presented.setup) this.#setupPending.update((n) => n + 1); + if (presented.token.byteLength > 0) this.#pending.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) { + async #run(wire: AuthWire, presented: Presented) { let result: Error | null = null; let closed = false; - try { - const stream = await wire.present(token.token); - token.stream = stream; - if (token.withdrawn) throw cancel(); - - for (;;) { - let reply: WireReply | undefined; - try { - reply = await wire.read(stream); - } catch (err) { - if ( - err instanceof StreamError || - err instanceof SessionError || - err instanceof Unsupported || - transportClosed(err) - ) - throw err; - throw new ProtocolViolation("malformed AUTH reply", { cause: err }); - } - if (!reply) { - // The peer ended the grant without revoking it, or closed without ever - // answering. Close our side too, which is what settles its Issued.closed. - result = token.isAnswered ? null : new Unsupported(); + // The peer refused the last attempt with a reply, rather than losing its stream. + let refused = false; + // The peer granted the token at some point, so a clean close ends the grant. + let granted = false; + let delay: Milli | undefined; + for (;;) { + refused = false; + // Whether this attempt's stream got a grant, so a close is not the peer predating AUTH. + let answered = false; + try { + const stream = await wire.present(presented.token); + presented.stream = stream; + if (presented.withdrawn || presented.ended) throw cancel(); + + for (;;) { + let reply: WireReply | undefined; + try { + reply = await wire.read(stream); + } catch (err) { + if ( + err instanceof StreamError || + err instanceof SessionError || + err instanceof Unsupported || + transportClosed(err) + ) + throw err; + throw new ProtocolViolation("malformed AUTH reply", { cause: err }); + } + if (!reply) { + // The peer ended the grant without revoking it, or closed without ever + // answering. Close our side too, which is what settles its Issued.closed. + result = answered ? null : new Unsupported(); + stream.close(); + break; + } + if ("grant" in reply) { + const expires = reply.grant.expires === undefined ? undefined : wall() + reply.grant.expires; + presented.grant = { publish: reply.grant.publish, subscribe: reply.grant.subscribe, expires }; + answered = granted = true; + delay = undefined; + this.#replied = true; + this.#answered(presented); + this.#recompute(); + continue; + } + console.warn("auth token refused", reply.refused); + // A refused setup token leaves an empty union, not an unknown (unrestricted) + // one. A grant the acceptor could not tell, or never judged, is unknown, not empty. + const unjudged = + reply.refused instanceof Unsupported || + (reply.refused instanceof StreamError && reply.refused.code === StreamCode.GoingAway); + if (!unjudged) this.#replied = true; + result = reply.refused; + refused = true; stream.close(); break; } - if ("grant" in reply) { - const expires = reply.grant.expires === undefined ? undefined : wall() + reply.grant.expires; - token.grant.set({ publish: reply.grant.publish, subscribe: reply.grant.subscribe, expires }); - this.#replied = true; - this.#answered(token); - this.#recompute(); - continue; + } catch (err: unknown) { + closed = transportClosed(err); + if (presented.withdrawn || presented.ended) { + result = null; + } else if (!answered && err instanceof StreamError) { + // A peer that predates AUTH resets a stream type it does not know. + result = new Unsupported(); + } else { + result = error(err); + if (result instanceof ProtocolViolation && !this.#closed && this.violated.peek() === undefined) { + this.violated.set(result); + } + if (!this.#closed) console.warn("auth token ended", result); } - console.warn("auth token refused", reply.refused); - // A refused setup token leaves an empty union, not an unknown (unrestricted) - // one. A grant the acceptor could not tell, or never judged, is unknown, not empty. - const unjudged = - reply.refused instanceof Unsupported || - (reply.refused instanceof StreamError && reply.refused.code === StreamCode.GoingAway); - if (!unjudged) this.#replied = true; - result = reply.refused; - stream.close(); - break; } - } catch (err: unknown) { - closed = transportClosed(err); - if (token.withdrawn) { + presented.stream = undefined; + + // A refusal that may pass, such as an unreachable auth server: present the token + // again after a backoff while it is still wanted here. Only moq-lite's AUTH_ERROR + // carries such a code. + if (!refused || !transient(result) || this.#closed || presented.withdrawn) break; + presented.grant = undefined; + // Answered, so nothing waits on the token through the backoff. + this.#settle(presented); + this.#recompute(); + delay = delay === undefined ? RETRY_INITIAL : Milli(Math.min(delay * 2, RETRY_MAX)); + presented.retry = new AbortController(); + await sleep(Nano.fromMilli(delay), { signal: presented.retry.signal }).catch(() => void 0); + presented.retry = undefined; + if (this.#closed || presented.withdrawn || presented.ended) { + refused = false; result = null; - } else if (!token.isAnswered && err instanceof StreamError) { - // A peer that predates AUTH resets a stream type it does not know. - result = new Unsupported(); - } else { - result = error(err); - if (result instanceof ProtocolViolation && !this.#closed && this.violated.peek() === undefined) { - this.violated.set(result); - } - if (!this.#closed) console.warn("auth token ended", result); + break; } } + // Already ended by the session's close. + if (presented.ended) return; + presented.ended = true; + presented.grant = undefined; // A token that ends unanswered counts as answered for enforcement: its refusal is // the reply. - const unanswered = !token.isAnswered; - token.end(result); - if (unanswered && token.setup) this.#setupPending.update((n) => n - 1); - if (unanswered && token.token.byteLength > 0 && !this.#closed) this.#pending.update((n) => n - 1); - this.#tokens.delete(token); + this.#settle(presented); + this.#presented.delete(presented); + const owner = presented.owner; + if (owner && this.#byToken.get(owner) === presented) this.#byToken.delete(owner); + this.#ended.update((n) => n + 1); + // Only the peer's verdict ends one of the handle's tokens on every session: a refusal, + // Unsupported, or a clean close after a grant. Anything else leaves it for the next. + const verdict = + !this.#closed && + !presented.withdrawn && + (refused || result instanceof Unsupported || (result === null && granted)); + if (owner && verdict) this.#state.verdict(owner, result); // Closed before the union shrinks, so the scope never reads it as a revocation. if (closed) this.close(); this.#recompute(); } - #answered(token: Presented) { - if (token.isAnswered) return; - token.answer(); - if (token.setup) this.#setupPending.update((n) => n - 1); - if (token.token.byteLength > 0 && !this.#closed) this.#pending.update((n) => n - 1); + /** The peer granted `presented`: it counts as answered, and so does the handle's token. */ + #answered(presented: Presented) { + this.#settle(presented); + presented.owner?.answer(); + } + + /** Stop counting `presented` as unanswered, for enforcement and the setup wait. */ + #settle(presented: Presented) { + if (presented.isAnswered) return; + presented.isAnswered = true; + if (presented.setup) this.#setupPending.update((n) => n - 1); + if (presented.token.byteLength > 0 && !this.#closed) this.#pending.update((n) => n - 1); } #recompute() { const granted: Grant[] = []; - for (const token of this.#tokens) { - const grant = token.grant.peek(); - if (grant) granted.push(grant); + for (const presented of this.#presented) { + if (presented.grant) granted.push(presented.grant); } // Undefined until the first reply; an empty union afterwards grants nothing. - if (granted.length === 0 && !this.#replied) return; - const next = union(granted); - if (!grantsEqual(next, this.#union.peek())) this.#union.set(next); - if (!this.#closed && !grantsEqual(next, this.#scope.peek())) this.#scope.set(next); + if (granted.length > 0 || this.#replied) { + const next = union(granted); + if (!grantsEqual(next, this.#union.peek())) this.#union.set(next); + if (!this.#closed && !grantsEqual(next, this.#scope.peek())) this.#scope.set(next); + } + this.#state.refresh(); } } diff --git a/js/net/src/connection/connect.ts b/js/net/src/connection/connect.ts index e4b56b00f87..543041d8c48 100644 --- a/js/net/src/connection/connect.ts +++ b/js/net/src/connection/connect.ts @@ -1,5 +1,7 @@ import { Milli, Nano, sleep } from "@moq/clock"; import Session, { type Version as QmuxVersion } from "@moq/qmux"; +import type * as Auth from "../auth.ts"; +import { vacant } from "../auth_session.ts"; import { error, fromClose, ProtocolViolation, sessionCause } from "../error.ts"; import * as Ietf from "../ietf/index.ts"; import * as Lite from "../lite/index.ts"; @@ -202,6 +204,16 @@ export interface ConnectProps { /** The moq-transport extensions to offer, each on unless set to `false`. */ extensions?: Extensions.Extensions; + /** + * Build the session on `auth`, so it presents every token the handle holds and a route + * naming one of its `Auth`s keeps it across a reconnect. Omit to give the session a handle + * of its own, which ends with it. + * + * The handle backs one active session at a time, plus the ones draining after a GOAWAY: + * connecting another while one is still active rejects with {@link Auth.Duplicate}. + */ + auth?: Auth.Handle; + /** * Aborts the connection attempt with the signal's reason. An already-aborted * signal rejects before anything opens, and aborting after the connection is @@ -213,6 +225,8 @@ export interface ConnectProps { /** The per-session wiring shared by every negotiated protocol path. */ type SessionProps = { publish?: OriginConsumer; + /** The auth handle the session is built on, or undefined for one of its own. */ + handle?: Auth.Handle; /** Whether this side dialed; only the dialing side aborts on a publication its grant does not cover. */ client: boolean; /** The moq-transport extensions this side offers. */ @@ -238,6 +252,8 @@ export async function connect({ url, ...props }: ConnectProps): Promise(); @@ -264,6 +280,7 @@ export async function connect({ url, ...props }: ConnectProps): Promise, abort: Promise): Promise { const wiring: SessionProps = { publish: props.publish, + handle: props.auth, client: true, extensions: Extensions.offered(props.extensions), }; diff --git a/js/net/src/connection/established.ts b/js/net/src/connection/established.ts index eeddf689af8..f28fbedf941 100644 --- a/js/net/src/connection/established.ts +++ b/js/net/src/connection/established.ts @@ -33,6 +33,9 @@ export interface Established { * presents. On moq-lite-07-wip, and on moq-transport draft-17+ when both sides negotiate * MoQ Auth, each side presents its connection's credential right after setup. * Otherwise the grant stays undefined. + * + * The handle passed to `connect` as `auth`, or else one the session created for itself, + * which ends with it. */ readonly auth: Auth.Handle; diff --git a/js/net/src/connection/forward.test.ts b/js/net/src/connection/forward.test.ts index ed3350c345a..9a737265a5e 100644 --- a/js/net/src/connection/forward.test.ts +++ b/js/net/src/connection/forward.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, expect, jest, spyOn, test } from "bun:test"; import { Once } from "@moq/signals"; import * as Announce from "../announced.ts"; +import { AuthSession } from "../auth_session.ts"; import { type Consumer as BroadcastConsumer, Producer as BroadcastProducer } from "../broadcast.ts"; import * as Epoch from "../epoch.ts"; import { Route } from "../hop.ts"; @@ -49,7 +50,11 @@ class FakeSession { #die!: (reason: Error | null) => void; constructor() { - registerWire(this, { consume: (path) => this.consume(path), goaway: new Once() }); + registerWire(this, { + consume: (path) => this.consume(path), + goaway: new Once(), + auth: new AuthSession({ peerGrant: { publish: new Path.Patterns(), subscribe: new Path.Patterns() } }), + }); this.closed = new Promise((resolve) => { this.#die = resolve; }); diff --git a/js/net/src/connection/migrate.test.ts b/js/net/src/connection/migrate.test.ts index 46674c8f356..0b587ee8bb8 100644 --- a/js/net/src/connection/migrate.test.ts +++ b/js/net/src/connection/migrate.test.ts @@ -271,3 +271,64 @@ test("a redirect onto an already pooled key leaves both entries to their handles resident.close(); } }); + +test("the connection's tokens ride every session across a GOAWAY", async () => { + // Every dial gets a lite-07 session that grants each token it is presented. + const relays: { server: WebTransport; tokens: string[]; withdrawn: string[] }[] = []; + const stub = function StubWebTransport(dialed: string | URL) { + const pair = createMockTransportPair(Lite.ALPN_07_WIP); + const relay = { server: pair.server, tokens: [] as string[], withdrawn: [] as string[] }; + relays.push(relay); + void accept({ transport: pair.server, url: new URL(dialed) }).then((session) => { + const requests = session.auth.requests(); + void (async () => { + for (;;) { + const request = await requests.next(); + if (!request) break; + const token = new TextDecoder().decode(request.token); + relay.tokens.push(token); + const issued = request.accept({ + publish: new Path.Patterns([Path.Pattern.all()]), + subscribe: new Path.Patterns(), + }); + void issued.closed.then(() => relay.withdrawn.push(token)); + } + })(); + }); + return pair.client; + }; + globalThis.WebTransport = stub as unknown as typeof WebTransport; + + const reload = new Reload({ + url, + websocket: { enabled: false }, + versions: [Lite.ALPN_07_WIP], + goaway: { handover }, + delay: { initial: Milli(1) }, + }); + const token = await reload.auth.add("token"); + const first = relays[0]; + if (!first) throw new Error("no first dial"); + expect(first.tokens).toContain("token"); + + const stream = await Stream.open(first.server, { version: Lite.Version.DRAFT_07 }); + await stream.writer.u53(Lite.StreamId.Goaway); + await new Lite.Goaway("").encode(stream.writer, Lite.Version.DRAFT_07); + + // The replacement presents it with no help from the application, while the draining + // session keeps presenting it beside its successor. + await waitUntil(() => relays[1]?.tokens.includes("token") === true); + await waitUntil(() => token.grant.peek() !== undefined && reload.established.peek() !== undefined); + await settle(); + expect(first.withdrawn).not.toContain("token"); + let ended = false; + void token.closed.then(() => { + ended = true; + }); + await settle(); + expect(ended).toBe(false); + + // The tokens end with the loop. + reload.close(); + expect(await token.closed).toBeInstanceOf(Error); +}); diff --git a/js/net/src/connection/pool.ts b/js/net/src/connection/pool.ts index 66b7dc6ec17..fb4cca8a423 100644 --- a/js/net/src/connection/pool.ts +++ b/js/net/src/connection/pool.ts @@ -7,6 +7,7 @@ import { Milli, Nano } from "@moq/clock"; import { Effect, type GetPromise, type Getter, Once, Signal } from "@moq/signals"; import * as Announce from "../announced.ts"; +import type * as Auth from "../auth.ts"; import type { Handle } from "../bandwidth.ts"; import * as Origin from "../origin.ts"; import * as Path from "../path.ts"; @@ -179,6 +180,15 @@ export class Connection { */ readonly origin: Getter; + /** + * The auth handle for the current URL, or undefined while disabled or URL-less. + * + * Every session the connection makes presents the tokens it holds, so a route naming one + * keeps it across a reconnect, and its grant follows the live session. A shared handle's + * is the same one every other handle on this URL uses, since they share the session. + */ + readonly auth: Getter; + /** * Settles once this handle is released via {@link close}: `null` on a clean close, or * the abort {@link Error}. Peek it synchronously (`undefined` while open), observe it @@ -194,6 +204,7 @@ export class Connection { readonly #probe = new Signal | undefined>(undefined); readonly #origin = new Signal(undefined); readonly #bandwidth = new Signal(undefined); + readonly #auth = new Signal(undefined); readonly #error = new Signal(undefined); #signals: Effect; @@ -217,6 +228,7 @@ export class Connection { this.probe = new Follow(this.#probe); this.origin = this.#origin; this.bandwidth = this.#bandwidth; + this.auth = this.#auth; this.transport = this.#signals.computed((effect) => effect.get(this.#established)?.transport); const linger = props?.linger; @@ -240,6 +252,7 @@ export class Connection { effect.set(this.#origin, lease.origin, undefined); effect.set(this.#bandwidth, lease.connection.bandwidth, undefined); + effect.set(this.#auth, lease.connection.auth, undefined); // Proxy the shared loop's outputs, so this handle reads like its own connection. effect.run((nested) => nested.set(this.#status, nested.get(lease.connection.status), "disconnected")); @@ -271,6 +284,7 @@ export class Connection { if (!effect.get(this.enabled) || !effect.get(href)) return; effect.set(this.#origin, origin, undefined); effect.set(this.#bandwidth, loop.bandwidth, undefined); + effect.set(this.#auth, loop.auth, undefined); }); this.#signals.run((effect) => effect.set(this.#status, effect.get(loop.status), "disconnected")); this.#signals.run((effect) => effect.set(this.#established, effect.get(loop.established), undefined)); diff --git a/js/net/src/connection/reload.ts b/js/net/src/connection/reload.ts index 8d5bab18ca0..1797fd38ce3 100644 --- a/js/net/src/connection/reload.ts +++ b/js/net/src/connection/reload.ts @@ -1,6 +1,7 @@ import { Instant, Milli, Nano, now, sleep } from "@moq/clock"; import { Effect, type GetPromise, type Getter, Once, Signal } from "@moq/signals"; import * as Announce from "../announced.ts"; +import { Handle as AuthHandle } from "../auth.ts"; import { Allocator } from "../bandwidth.ts"; import { error, SessionCode, SessionError } from "../error.ts"; import type { Consumer as OriginConsumer, Producer as OriginProducer } from "../origin.ts"; @@ -142,6 +143,14 @@ export class Reload { */ readonly bandwidth: Allocator; + /** + * The auth handle every session of the loop is built on, spanning reconnects. + * + * Each session presents every token it holds, so a route naming one keeps it across a + * reconnect, and its grant follows the live session. It ends when the loop closes. + */ + readonly auth = new AuthHandle(); + /** Protocol versions to offer, most preferred first, kept across retries and redirects. */ readonly versions?: readonly string[]; @@ -403,6 +412,7 @@ export class Reload { webtransport: this.webtransport, publish: this.publish, consume: this.consume, + auth: this.auth, signal, }); } finally { @@ -639,6 +649,8 @@ export class Reload { close(abort?: Error) { this.#signals.close(); this.#draining?.retire(); + // No session will present the tokens again, so they end with the loop. + this.auth.abort(abort ?? new Error("connection closed")); if (this.#closed.peek() === undefined) this.#closed.set(abort ?? null); } } diff --git a/js/net/src/ietf/connection.ts b/js/net/src/ietf/connection.ts index 9d50a2bbe0c..00782ee6ff3 100644 --- a/js/net/src/ietf/connection.ts +++ b/js/net/src/ietf/connection.ts @@ -62,7 +62,7 @@ export class Connection implements Established { /** Our tokens and grants, when the peer negotiated MoQ Auth; see {@link Established.auth}. */ get auth(): Auth.Handle { - return this.#auth; + return this.#auth.handle; } // Our tokens and grants, and the answers to the peer's (MoQ Auth). @@ -128,6 +128,7 @@ export class Connection implements Established { activeCount = false, cluster, auth = false, + handle, epoch = false, early = [], requestWindow, @@ -160,6 +161,8 @@ export class Connection implements Established { cluster?: Cluster.Hops; /** Whether MoQ Auth is negotiated (draft-17+): offered by both SETUPs. */ auth?: boolean; + /** The auth handle to build the session on, or undefined for one of its own. */ + handle?: Auth.Handle; /** Whether MoQ Epoch is negotiated (draft-17+): offered by both SETUPs. */ epoch?: boolean; /** Uni streams that arrived before the peer's SETUP, type unread (v17+). */ @@ -214,7 +217,13 @@ export class Connection implements Established { publish: new Path.Patterns([Path.Pattern.all()]), subscribe: new Path.Patterns(publish ? [Path.Pattern.all()] : []), }, + handle, + draining: () => this.#goaway.peek() !== undefined, }); + // Left before anything observes the close, so a successor on the same handle never + // finds this session still active. + const leave = () => this.#auth.close(); + void quic.closed.then(leave, leave); this.#client = client; this.#auth.violated.subscribe((err) => { @@ -251,7 +260,11 @@ export class Connection implements Established { grant: this.#auth.scope, auth: this.#auth, }); - registerWire(this, { consume: (path) => this.#subscriber.consume(path), goaway: this.#goaway }); + registerWire(this, { + consume: (path) => this.#subscriber.consume(path), + goaway: this.#goaway, + auth: this.#auth, + }); void this.#run(early); } diff --git a/js/net/src/lite/connection.ts b/js/net/src/lite/connection.ts index 86dc40c6bc3..2765ee19d95 100644 --- a/js/net/src/lite/connection.ts +++ b/js/net/src/lite/connection.ts @@ -55,6 +55,8 @@ export interface ConnectionProps { session?: Stream; /** The origin whose broadcasts are served to the peer. Omit to publish nothing. */ publish?: OriginConsumer; + /** The auth handle to build the session on, or undefined for one of its own. */ + handle?: Auth.Handle; /** * Whether this side dialed. Only the dialing side aborts on a publication its grant does * not cover: a server publishes whatever the peer may read, not what it intends to push. @@ -140,7 +142,7 @@ export class Connection implements Established { * * @internal */ - constructor({ url, quic, version, session, publish, client = true }: ConnectionProps) { + constructor({ url, quic, version, session, publish, handle, client = true }: ConnectionProps) { this.url = url; this.#quic = quic; this.#session = session; @@ -164,7 +166,13 @@ export class Connection implements Established { publish: new Path.Patterns([Path.Pattern.all()]), subscribe: new Path.Patterns(publish ? [Path.Pattern.all()] : []), }, + handle, + draining: () => this.#goaway.peek() !== undefined, }); + // Left before anything observes the close, so a successor on the same handle never + // finds this session still active. + const leave = () => this.#auth.close(); + void quic.closed.then(leave, leave); this.#auth.violated.subscribe((err) => { if (!err) return; closeViolation(this.#quic, err); @@ -187,14 +195,18 @@ export class Connection implements Established { this.#auth, ); this.#client = client; - registerWire(this, { consume: (path) => this.#subscriber.consume(path), goaway: this.#goaway }); + registerWire(this, { + consume: (path) => this.#subscriber.consume(path), + goaway: this.#goaway, + auth: this.#auth, + }); void this.#run(); } /** Our tokens and grants; see {@link Established.auth}. */ get auth(): Auth.Handle { - return this.#auth; + return this.#auth.handle; } /** diff --git a/js/net/src/wire.ts b/js/net/src/wire.ts index 1ae5b386c53..4a9eb4143c4 100644 --- a/js/net/src/wire.ts +++ b/js/net/src/wire.ts @@ -8,6 +8,7 @@ * @module */ import type { Dispose, GetPromise, Getter } from "@moq/signals"; +import type { AuthSession } from "./auth_session.ts"; import type * as broadcast from "./broadcast.ts"; import type { Drain } from "./connection/goaway.ts"; import type * as Epoch from "./epoch.ts"; @@ -139,6 +140,9 @@ export interface Established { // private: plumbing /** Settles with the peer's GOAWAY; the session keeps serving until it closes. */ readonly goaway: GetPromise; + // private: plumbing + /** This session's side of its auth handle. */ + readonly auth: AuthSession; } type View = Broadcast | OriginProducer | OriginConsumer | Established; From 03d536c2c0079a21f1b57a88ed8f7c6670916fc1 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 20 Oct 2026 17:30:19 -0700 Subject: [PATCH 07/45] fix(auth): keep the session's auth side out of the public d.ts Mark the wire view's auth field internal so the declaration build no longer imports an internal type, and satisfy clippy's field_reassign_with_default in the moq-tokio reconnect test. Co-Authored-By: Claude Opus 5.5 --- js/net/src/wire.ts | 9 ++++++--- rs/moq-tokio/src/connection.rs | 6 ++++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/js/net/src/wire.ts b/js/net/src/wire.ts index 4a9eb4143c4..0047c11ca0f 100644 --- a/js/net/src/wire.ts +++ b/js/net/src/wire.ts @@ -8,7 +8,6 @@ * @module */ import type { Dispose, GetPromise, Getter } from "@moq/signals"; -import type { AuthSession } from "./auth_session.ts"; import type * as broadcast from "./broadcast.ts"; import type { Drain } from "./connection/goaway.ts"; import type * as Epoch from "./epoch.ts"; @@ -141,8 +140,12 @@ export interface Established { /** Settles with the peer's GOAWAY; the session keeps serving until it closes. */ readonly goaway: GetPromise; // private: plumbing - /** This session's side of its auth handle. */ - readonly auth: AuthSession; + /** + * This session's side of its auth handle. + * + * @internal + */ + readonly auth: import("./auth_session.ts").AuthSession; } type View = Broadcast | OriginProducer | OriginConsumer | Established; diff --git a/rs/moq-tokio/src/connection.rs b/rs/moq-tokio/src/connection.rs index 104f7a889d2..5108ab5ad80 100644 --- a/rs/moq-tokio/src/connection.rs +++ b/rs/moq-tokio/src/connection.rs @@ -2416,8 +2416,10 @@ mod tests { let mut listener = config.init(Default::default()).unwrap().listen().await.unwrap(); let addr = listener.tcp_local_addr().unwrap(); - let mut config = crate::connect::Config::default(); - config.version = vec![version]; + let mut config = crate::connect::Config { + version: vec![version], + ..Default::default() + }; config.backoff.initial = Some(MIN_BACKOFF); config.backoff.max = Some(MIN_BACKOFF); let connection = config From 52729c2aa9d9cfd5ab82282393398842d4280a05 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 20 Oct 2026 17:30:22 -0700 Subject: [PATCH 08/45] quest: finish m1/auth/issuer Co-Authored-By: Claude Opus 5.5 --- quest/m0/audit-quests.md | 4 --- quest/m1/auth/README.md | 1 - quest/m1/auth/issuer.md | 55 ---------------------------------------- 3 files changed, 60 deletions(-) delete mode 100644 quest/m1/auth/issuer.md diff --git a/quest/m0/audit-quests.md b/quest/m0/audit-quests.md index 556e8afcf50..2a2ea913f41 100644 --- a/quest/m0/audit-quests.md +++ b/quest/m0/audit-quests.md @@ -64,10 +64,6 @@ Checkpoint, 2026-10-19, against `main` at `2e1ec2ebf`: This run remains open, with no successor, until the unresolved conflicts are settled. The unattended instruction was to skip uncertain choices: -- [Auth issuer](/quest/m1/auth/issuer.md) specifies a shared handle and one - live session's grant, but not how independent concurrent sessions select - grants and refusals. Existing reconnect code only designates one active - generation alongside draining predecessors. - [Binding linger](/quest/m1/linger-ffi.md) extends the hand-written C ABI, while [generated C](/quest/m1/c/README.md) freezes it except claim safety. - [Catalog HLS](/quest/m1/archive/catalog-hls.md) names `.hls.z` tracks, diff --git a/quest/m1/auth/README.md b/quest/m1/auth/README.md index 17f6a6024d9..56adfa5c3a1 100644 --- a/quest/m1/auth/README.md +++ b/quest/m1/auth/README.md @@ -144,7 +144,6 @@ because `main` assigned 0x3A to NOT_FETCHABLE first. ## Required -- [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 - [Bindings](/quest/m1/auth/bindings.md) - grants and tokens reach every binding through moq-ffi - [Token in band](/quest/m1/auth/token-in-band.md) - extra credentials ride diff --git a/quest/m1/auth/issuer.md b/quest/m1/auth/issuer.md deleted file mode 100644 index 98b69e5bcb7..00000000000 --- a/quest/m1/auth/issuer.md +++ /dev/null @@ -1,55 +0,0 @@ -# [L] Auth outlives its session - -## Goal - -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 - `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. -- Decided 2026-10-20 (maintainer): a handle backs one active session at a - time, plus the predecessors it is draining, matching the reconnect code's - single active generation. Attaching a handle to a second concurrent session - fails loudly; independent concurrent sessions use separate handles, so an - `Auth` never has to pick between two live grants. -- 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 From 88e31cd9fde590032b7ad04e1fa4ec1392f78a93 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 20 Oct 2026 17:34:01 -0700 Subject: [PATCH 09/45] fix(moq-net): build the auth link without the async feature Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/auth.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index 1a5376b5b3c..868e4c61203 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -791,6 +791,7 @@ impl Link { } /// The handle this session is built on. + #[cfg_attr(not(feature = "async"), allow(dead_code))] pub(crate) fn handle(&self) -> &Handle { &self.handle } @@ -825,6 +826,7 @@ impl Link { /// Retire the session to draining once `goaway` is sent or received, freeing the /// handle for a replacement. + #[cfg_attr(not(feature = "async"), allow(dead_code))] pub(crate) fn drain_with(&self, goaway: std::sync::Arc) { self.with(|link| link.goaway = Some(goaway)); } From bd0f1c5329764d1179f80b86a6c3ce176fa3c7fe Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 20 Oct 2026 17:55:42 -0700 Subject: [PATCH 10/45] test(js/net): drive the GOAWAY token test with a manual clock Co-Authored-By: Claude Opus 5.5 --- js/net/src/connection/migrate.test.ts | 68 +++++++++++++++++---------- 1 file changed, 43 insertions(+), 25 deletions(-) diff --git a/js/net/src/connection/migrate.test.ts b/js/net/src/connection/migrate.test.ts index 0b587ee8bb8..e6072dca4be 100644 --- a/js/net/src/connection/migrate.test.ts +++ b/js/net/src/connection/migrate.test.ts @@ -1,11 +1,12 @@ import { afterEach, expect, test } from "bun:test"; -import { Milli } from "@moq/clock"; +import { Manual, Milli, Nano } from "@moq/clock"; import { RefusedRedirect } from "../error.ts"; import * as Lite from "../lite/index.ts"; import { createMockTransportPair } from "../mock.ts"; import { Producer as OriginProducer } from "../origin.ts"; import * as Path from "../path.ts"; import { Stream } from "../stream.ts"; +import { nextTask } from "../util/yield.ts"; import { accept } from "./accept.ts"; import { Connection, resetShared } from "./pool.ts"; import { Reload } from "./reload.ts"; @@ -273,6 +274,18 @@ test("a redirect onto an already pooled key leaves both entries to their handles }); test("the connection's tokens ride every session across a GOAWAY", async () => { + const clock = new Manual(); + const restore = clock.install(); + // Bounded by task turns rather than wall time, advancing the clock a millisecond per turn + // so the reconnect's delay passes long before the draining session's handover. + const until = async (done: () => boolean) => { + for (let i = 0; i < 100 && !done(); i++) { + await nextTask(); + clock.advance(Nano.fromMilli(Milli(1))); + } + expect(done()).toBe(true); + }; + // Every dial gets a lite-07 session that grants each token it is presented. const relays: { server: WebTransport; tokens: string[]; withdrawn: string[] }[] = []; const stub = function StubWebTransport(dialed: string | URL) { @@ -306,29 +319,34 @@ test("the connection's tokens ride every session across a GOAWAY", async () => { goaway: { handover }, delay: { initial: Milli(1) }, }); - const token = await reload.auth.add("token"); - const first = relays[0]; - if (!first) throw new Error("no first dial"); - expect(first.tokens).toContain("token"); - - const stream = await Stream.open(first.server, { version: Lite.Version.DRAFT_07 }); - await stream.writer.u53(Lite.StreamId.Goaway); - await new Lite.Goaway("").encode(stream.writer, Lite.Version.DRAFT_07); - - // The replacement presents it with no help from the application, while the draining - // session keeps presenting it beside its successor. - await waitUntil(() => relays[1]?.tokens.includes("token") === true); - await waitUntil(() => token.grant.peek() !== undefined && reload.established.peek() !== undefined); - await settle(); - expect(first.withdrawn).not.toContain("token"); - let ended = false; - void token.closed.then(() => { - ended = true; - }); - await settle(); - expect(ended).toBe(false); + try { + const token = await reload.auth.add("token"); + const first = relays[0]; + if (!first) throw new Error("no first dial"); + expect(first.tokens).toContain("token"); + + const stream = await Stream.open(first.server, { version: Lite.Version.DRAFT_07 }); + await stream.writer.u53(Lite.StreamId.Goaway); + await new Lite.Goaway("").encode(stream.writer, Lite.Version.DRAFT_07); + + // The replacement presents it with no help from the application, while the draining + // session keeps presenting it beside its successor. + await until(() => relays[1]?.tokens.includes("token") === true); + await until(() => token.grant.peek() !== undefined && reload.established.peek() !== undefined); + await nextTask(); + expect(first.withdrawn).not.toContain("token"); + let ended = false; + void token.closed.then(() => { + ended = true; + }); + await nextTask(); + expect(ended).toBe(false); - // The tokens end with the loop. - reload.close(); - expect(await token.closed).toBeInstanceOf(Error); + // The tokens end with the loop. + reload.close(); + expect(await token.closed).toBeInstanceOf(Error); + } finally { + reload.close(); + restore(); + } }); From e199b674dbf3ddb7972e29e393bd21dd20eb49ee Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 20 Oct 2026 17:55:58 -0700 Subject: [PATCH 11/45] quest(m1): claim auth/bindings Co-Authored-By: Claude Opus 5.5 From 30acf7d9e632c3efba26b5b954a2de7b37649efa Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 20 Oct 2026 18:04:34 -0700 Subject: [PATCH 12/45] feat(moq-ffi): expose session auth grants and tokens Co-Authored-By: Claude Opus 5.5 --- rs/moq-ffi/src/auth.rs | 214 ++++++++++++++++++++++++++++++++++++++ rs/moq-ffi/src/lib.rs | 1 + rs/moq-ffi/src/session.rs | 16 +++ rs/moq-ffi/src/test.rs | 130 +++++++++++++++++++++++ 4 files changed, 361 insertions(+) create mode 100644 rs/moq-ffi/src/auth.rs diff --git a/rs/moq-ffi/src/auth.rs b/rs/moq-ffi/src/auth.rs new file mode 100644 index 00000000000..5afbfa1602a --- /dev/null +++ b/rs/moq-ffi/src/auth.rs @@ -0,0 +1,214 @@ +//! In-band authorization: what the peer lets a session do, and more tokens to present. + +use std::sync::Arc; + +use crate::error::MoqError; +use crate::ffi::Task; + +/// What the peer lets this side do, in this side's own paths. +/// +/// An empty pattern list grants nothing in that direction; `**` grants everything. +#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)] +pub struct MoqGrant { + /// The broadcast patterns this side may publish to the peer. + pub publish: Vec, + /// The broadcast patterns this side may subscribe to from the peer. + pub subscribe: Vec, + /// Microseconds until the grant lapses, measured when it was read, or `None` for never. + pub expires_us: Option, +} + +impl From for MoqGrant { + fn from(grant: moq_net::auth::Grant) -> Self { + let patterns = |patterns: &moq_net::Patterns| patterns.iter().map(ToString::to_string).collect(); + Self { + publish: patterns(&grant.publish), + subscribe: patterns(&grant.subscribe), + expires_us: grant.expires.map(|expires| { + let remaining = expires.saturating_duration_since(moq_net::time::now()); + u64::try_from(remaining.as_micros()).unwrap_or(u64::MAX) + }), + } + } +} + +/// Where a session's tokens live: on the session itself, or on a connection that presents +/// them again on every reconnect. +#[derive(Clone)] +pub(crate) enum Handle { + Session(moq_net::auth::Handle), + #[cfg(not(target_arch = "wasm32"))] + Connection(moq_tokio::connection::auth::Handle), +} + +impl Handle { + fn grant(&self) -> Watch { + match self { + Self::Session(handle) => Watch::Session(handle.grant()), + #[cfg(not(target_arch = "wasm32"))] + Self::Connection(handle) => Watch::Connection(handle.grant()), + } + } + + async fn add(self, token: String) -> Result { + match self { + Self::Session(handle) => Ok(Token::Session(handle.add(token).await?)), + #[cfg(not(target_arch = "wasm32"))] + Self::Connection(handle) => Ok(Token::Connection( + handle.add(token).await.map_err(crate::session::map_closed_error)?, + )), + } + } +} + +enum Watch { + Session(moq_net::auth::Watch), + #[cfg(not(target_arch = "wasm32"))] + Connection(moq_tokio::connection::auth::Watch), +} + +impl Watch { + fn peek(&self) -> Option { + match self { + Self::Session(watch) => watch.peek(), + #[cfg(not(target_arch = "wasm32"))] + Self::Connection(watch) => watch.peek(), + } + .map(Into::into) + } + + async fn changed(&mut self) -> Result, MoqError> { + let grant = match self { + Self::Session(watch) => watch.changed().await?, + #[cfg(not(target_arch = "wasm32"))] + Self::Connection(watch) => watch.changed().await.map_err(crate::session::map_closed_error)?, + }; + Ok(grant.map(Into::into)) + } +} + +enum Token { + Session(moq_net::auth::Auth), + #[cfg(not(target_arch = "wasm32"))] + Connection(moq_tokio::connection::auth::Auth), +} + +impl Token { + fn grant(&self) -> Watch { + match self { + Self::Session(auth) => Watch::Session(auth.grant()), + #[cfg(not(target_arch = "wasm32"))] + Self::Connection(auth) => Watch::Connection(auth.grant()), + } + } + + async fn closed(&self) -> Result<(), MoqError> { + match self { + Self::Session(auth) => match auth.closed().await { + moq_net::Error::Cancel => Ok(()), + err => Err(err.into()), + }, + #[cfg(not(target_arch = "wasm32"))] + Self::Connection(auth) => auth.closed().await.map_err(crate::session::map_closed_error), + } + } +} + +/// A session's authorization: the grant its peer gave it, and more tokens to present. +/// +/// Returned by [`MoqSession::auth`](crate::session::MoqSession::auth). AUTH is only on +/// `moq-lite-07-wip` and on moq-transport sessions that negotiate the MoQ Auth extension; +/// elsewhere the grant stays `None`. +#[derive(uniffi::Object)] +pub struct MoqAuth { + handle: Handle, + /// The watch behind [`grant`](Self::grant), which reads without the lock. + peek: Watch, + /// The watch behind [`grant_changed`](Self::grant_changed), which tracks what this + /// handle last reported. + changed: Task, +} + +impl MoqAuth { + pub(crate) fn new(handle: Handle) -> Self { + Self { + peek: handle.grant(), + changed: Task::new(handle.grant()), + handle, + } + } +} + +#[uniffi::export] +impl MoqAuth { + /// The union of every token's grant, or `None` until the peer first answers one. + /// + /// A client session reads `None` while it reconnects. + pub fn grant(&self) -> Option { + let _guard = crate::ffi::enter(); + self.peek.peek() + } + + /// Wait for the grant to differ from the one this handle last reported, returning it. + /// + /// Errors once nothing will change it again, because the session ended. + pub async fn grant_changed(&self) -> Result, MoqError> { + self.changed.run(|mut watch| async move { watch.changed().await }).await + } + + /// Present another token, resolving once the peer accepts it. + /// + /// Its grant joins the union until [`MoqAuthToken::cancel`]. A client session presents it + /// again on every reconnect. The peer's refusal is a [`MoqError::Protocol`] carrying its code. + /// + /// # Errors + /// + /// [`MoqError::Unsupported`] when the session cannot carry another token, such as a + /// moq-lite version without AUTH. + pub async fn add(&self, token: String) -> Result, MoqError> { + let handle = self.handle.clone(); + let token = crate::ffi::detached(handle.add(token)).await?; + Ok(Arc::new(MoqAuthToken::new(token))) + } +} + +/// One token presented with [`MoqAuth::add`]. +/// +/// It stays presented until [`cancel`](Self::cancel), or until the handle is released. +#[derive(uniffi::Object)] +pub struct MoqAuthToken { + /// The watch behind [`grant`](Self::grant), which does not keep the token presented. + peek: Watch, + token: Task, +} + +impl MoqAuthToken { + fn new(token: Token) -> Self { + Self { + peek: token.grant(), + token: Task::new(token), + } + } +} + +#[uniffi::export] +impl MoqAuthToken { + /// This token's own grant, or `None` while the peer has not answered it or after it ended. + pub fn grant(&self) -> Option { + let _guard = crate::ffi::enter(); + self.peek.peek() + } + + /// Wait for the token to end: `Ok` when the peer closed it without an error, the peer's + /// revocation as [`MoqError::Protocol`], or the session's close. + pub async fn closed(&self) -> Result<(), MoqError> { + self.token.run(|token| async move { token.closed().await }).await + } + + /// Withdraw the token, removing its grant from the union. + /// + /// Terminal: the token is withdrawn here, not when the handle is. + pub fn cancel(&self) { + self.token.cancel(); + } +} diff --git a/rs/moq-ffi/src/lib.rs b/rs/moq-ffi/src/lib.rs index 5b741a8efce..fbe7a3906b7 100644 --- a/rs/moq-ffi/src/lib.rs +++ b/rs/moq-ffi/src/lib.rs @@ -18,6 +18,7 @@ mod android; // since they are the heaviest thing in the dependency graph. #[cfg(all(feature = "audio", not(target_arch = "wasm32")))] pub mod audio; +pub mod auth; pub mod bandwidth; pub mod consumer; pub mod demand; diff --git a/rs/moq-ffi/src/session.rs b/rs/moq-ffi/src/session.rs index f2efee71ec7..6a37437104a 100644 --- a/rs/moq-ffi/src/session.rs +++ b/rs/moq-ffi/src/session.rs @@ -2,6 +2,7 @@ use std::sync::Arc; use url::Url; +use crate::auth::MoqAuth; use crate::bandwidth::MoqBandwidth; use crate::error::MoqError; use crate::ffi::Task; @@ -890,6 +891,7 @@ pub struct MoqSession { /// One allocator for the session. Every [`bandwidth`](Self::bandwidth) handle /// clones it, so they share one reservation registry. bandwidth: Arc, + auth: Arc, } impl MoqSession { @@ -943,7 +945,13 @@ impl MoqSession { let publisher = Arc::new(MoqOriginProducer::from_inner(publish)); let consumer = Arc::new(MoqOriginConsumer::from_inner(subscribe)); let bandwidth = Arc::new(MoqBandwidth::new(Self::mint_allocator(&inner))); + let auth = Arc::new(MoqAuth::new(match &inner { + #[cfg(not(target_arch = "wasm32"))] + Inner::Connection(connection) => crate::auth::Handle::Connection(connection.auth()), + Inner::Session(session) => crate::auth::Handle::Session(session.auth()), + })); Self { + auth, inner: inner.clone(), closed: Task::new(inner.clone()), status: Task::new(inner), @@ -1099,6 +1107,14 @@ impl MoqSession { self.bandwidth.clone() } + /// The session's authorization: the grant its peer gave it, and more tokens to present. + /// + /// Every call returns the same handle. A client session's handle survives reconnects, + /// presenting each added token again on every new connection. + pub fn auth(&self) -> Arc { + self.auth.clone() + } + /// Hold receive-estimate demand until the returned consumer is released. pub fn recv_bandwidth(&self) -> Option> { let consumer = match &self.inner { diff --git a/rs/moq-ffi/src/test.rs b/rs/moq-ffi/src/test.rs index 404a497b708..5f7a7a93ac9 100644 --- a/rs/moq-ffi/src/test.rs +++ b/rs/moq-ffi/src/test.rs @@ -6767,3 +6767,133 @@ async fn ascending_subscription_reads_a_cached_backlog_oldest_first() { } assert_eq!(sequences, vec![0, 1, 2]); } + +/// Wait until `auth` reports `want`, following its changes. +async fn await_grant(auth: &crate::auth::MoqAuth, want: crate::auth::MoqGrant) { + tokio::time::timeout(TIMEOUT, async { + while auth.grant().as_ref() != Some(&want) { + auth.grant_changed().await.expect("grant watch ended"); + } + }) + .await + .unwrap_or_else(|_| panic!("timed out waiting for {want:?}, last {:?}", auth.grant())); +} + +fn grant(publish: &[&str], subscribe: &[&str]) -> crate::auth::MoqGrant { + let patterns = |patterns: &[&str]| patterns.iter().map(|p| p.to_string()).collect(); + crate::auth::MoqGrant { + publish: patterns(publish), + subscribe: patterns(subscribe), + expires_us: None, + } +} + +/// Both sides of a session read the grant their peer gave them, and a peer that verifies no +/// tokens of its own refuses another one as unsupported. +#[tokio::test] +async fn auth_reads_the_peer_grant() { + let (server, client, accepted) = tokio::time::timeout(TIMEOUT, shutdown_pair()).await.expect("pair"); + assert!(Arc::ptr_eq(&client.auth(), &client.auth()), "one handle per session"); + + // Each side's origins span the root, so its peer grants it everything. + await_grant(&client.auth(), grant(&["**"], &["**"])).await; + await_grant(&accepted.auth(), grant(&["**"], &["**"])).await; + + let err = tokio::time::timeout(TIMEOUT, client.auth().add("extra".into())) + .await + .expect("add timed out") + .err() + .expect("the default acceptor refuses a token"); + assert!(matches!(err, MoqError::Unsupported), "refusal: {err:?}"); + assert_eq!(client.auth().grant(), Some(grant(&["**"], &["**"])), "a refusal leaves the union"); + + client.cancel(0); + accepted.cancel(0); + server.cancel(); +} + +/// A token added through a client session joins the union with its own grant, leaves it on +/// cancel, and a refusal or revocation carries the peer's code. +#[tokio::test] +async fn auth_tokens_join_and_leave_the_union() { + let version: moq_net::Version = "moq-lite-07-wip".parse().unwrap(); + let mut config = moq_tokio::listen::Config::default(); + config.tcp.bind = Some("127.0.0.1:0".parse().unwrap()); + config.version = vec![version]; + let mut listener = config.init(Default::default()).unwrap().listen().await.unwrap(); + let addr = listener.tcp_local_addr().unwrap(); + + let publish = |pattern: &str| moq_net::auth::Grant { + publish: pattern.parse::().unwrap().into(), + ..Default::default() + }; + let serving = tokio::spawn(async move { + let request = listener.accept().await.expect("accept"); + let mut requests = request.auth().requests().unwrap(); + let (session, driver) = request.ok().await.unwrap(); + let mut issued = Vec::new(); + while let Some(request) = requests.next().await { + match request.token().as_ref() { + b"" => issued.push(request.accept(publish("setup/**"))), + b"extra" => issued.push(request.accept(publish("extra/**"))), + b"revoked" => request + .accept(publish("revoked/**")) + .revoke(moq_net::SessionError::Expired, "revoked"), + _ => request.reject(moq_net::SessionError::Unauthorized, "unknown token"), + } + } + drop((session, driver, issued)); + }); + + let client = MoqClient::new(MoqClientConfig { + versions: vec!["moq-lite-07-wip".into()], + bind: Some("127.0.0.1:0".into()), + once: true, + ..Default::default() + }) + .unwrap(); + let session = tokio::time::timeout(TIMEOUT, client.connect(format!("tcp://{addr}"))) + .await + .expect("connect timed out") + .unwrap(); + let auth = session.auth(); + await_grant(&auth, grant(&["setup/**"], &[])).await; + + let extra = tokio::time::timeout(TIMEOUT, auth.add("extra".into())) + .await + .expect("add timed out") + .unwrap(); + assert_eq!(extra.grant(), Some(grant(&["extra/**"], &[]))); + // Patterns are kept sorted. + await_grant(&auth, grant(&["extra/**", "setup/**"], &[])).await; + + extra.cancel(); + await_grant(&auth, grant(&["setup/**"], &[])).await; + assert!(matches!(extra.closed().await, Err(MoqError::Cancelled))); + + let refused = tokio::time::timeout(TIMEOUT, auth.add("bogus".into())) + .await + .expect("add timed out") + .err() + .expect("refused"); + assert_protocol( + &refused, + crate::error::MoqErrorScope::Session, + MoqProtocolKind::Unauthorized, + ); + + let revoked = tokio::time::timeout(TIMEOUT, auth.add("revoked".into())) + .await + .expect("add timed out") + .unwrap(); + let closed = tokio::time::timeout(TIMEOUT, revoked.closed()) + .await + .expect("closed timed out") + .unwrap_err(); + assert_protocol(&closed, crate::error::MoqErrorScope::Session, MoqProtocolKind::Expired); + assert_eq!(revoked.grant(), None, "an ended token grants nothing"); + assert_eq!(auth.grant(), Some(grant(&["setup/**"], &[]))); + + session.cancel(0); + tokio::time::timeout(TIMEOUT, serving).await.expect("server ended").unwrap(); +} From f5a4377a92e076ddcd3e1a8d9bad5a51261400ab Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 20 Oct 2026 18:09:39 -0700 Subject: [PATCH 13/45] feat(py,go): wrap session auth Co-Authored-By: Claude Opus 5.5 --- go/wrapper/auth_test.go | 80 ++++++++++++++++++++++++++++++++++ go/wrapper/records.go | 18 ++++++++ go/wrapper/session.go | 62 ++++++++++++++++++++++++++ py/moq-rs/moq/__init__.py | 6 ++- py/moq-rs/moq/_records.py | 26 +++++++++++ py/moq-rs/moq/session.py | 65 ++++++++++++++++++++++++++- py/moq-rs/moq/types.py | 3 +- py/moq-rs/tests/test_server.py | 45 +++++++++++++++++++ 8 files changed, 301 insertions(+), 4 deletions(-) create mode 100644 go/wrapper/auth_test.go diff --git a/go/wrapper/auth_test.go b/go/wrapper/auth_test.go new file mode 100644 index 00000000000..66a762965aa --- /dev/null +++ b/go/wrapper/auth_test.go @@ -0,0 +1,80 @@ +package moq_test + +import ( + "context" + "errors" + "reflect" + "testing" + "time" + + "moq.dev/moq" +) + +// awaitGrant follows auth's changes until it reports want. +func awaitGrant(t *testing.T, ctx context.Context, auth *moq.Auth, want moq.Grant) { + t.Helper() + + for { + got := auth.Grant() + if got != nil && reflect.DeepEqual(*got, want) { + return + } + if _, err := auth.GrantChanged(ctx); err != nil { + t.Fatalf("grant = %+v, want %+v: %v", got, want, err) + } + } +} + +// Both sides read the grant their peer gave them, and a peer that verifies no +// tokens of its own refuses another one as unsupported. +func TestSessionAuthReadsTheGrant(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + const version = "moq-lite-07-wip" + server, err := moq.Listen(ctx, "127.0.0.1:0", moq.WithTLSGenerate("localhost"), moq.WithServerVersions(version)) + if err != nil { + t.Fatal(err) + } + defer server.Close() + + accepted := make(chan *moq.Session, 1) + go func() { + req, err := server.Accept(ctx) + if err != nil || req == nil { + return + } + session, err := req.Accept(ctx, nil, nil) + if err != nil { + return + } + accepted <- session + }() + + client, err := moq.Dial(ctx, "https://"+server.LocalAddr(), + moq.WithTLSVerify(false), moq.WithVersions(version), moq.WithReconnect(false)) + if err != nil { + t.Fatal(err) + } + defer client.Close() + + everything := moq.Grant{Publish: []string{"**"}, Subscribe: []string{"**"}} + auth := client.Session().Auth() + awaitGrant(t, ctx, auth, everything) + + var session *moq.Session + select { + case session = <-accepted: + case <-ctx.Done(): + t.Fatal("no session accepted") + } + defer session.Cancel(0) + awaitGrant(t, ctx, session.Auth(), everything) + + if _, err := auth.Add(ctx, "extra"); !errors.Is(err, moq.ErrUnsupported) { + t.Fatalf("add = %v, want ErrUnsupported", err) + } + if got := auth.Grant(); got == nil || !reflect.DeepEqual(*got, everything) { + t.Fatalf("grant after a refusal = %+v", got) + } +} diff --git a/go/wrapper/records.go b/go/wrapper/records.go index b6a5e827e71..9c6b3fe2a17 100644 --- a/go/wrapper/records.go +++ b/go/wrapper/records.go @@ -144,3 +144,21 @@ func connectionStatsFromFFI(s ffi.MoqConnectionStats) ConnectionStats { Version: s.Version, } } + +// Grant is what the peer lets this side do, in this side's own paths. An empty +// pattern list grants nothing in that direction; "**" grants everything. +type Grant struct { + // Publish lists the broadcast patterns this side may publish to the peer. + Publish []string + // Subscribe lists the broadcast patterns this side may subscribe to from the peer. + Subscribe []string + // Expires is how long until the grant lapses, measured when it was read, or nil for never. + Expires *time.Duration +} + +func grantFromFFI(g *ffi.MoqGrant) *Grant { + if g == nil { + return nil + } + return &Grant{Publish: g.Publish, Subscribe: g.Subscribe, Expires: optFromMicros(g.ExpiresUs)} +} diff --git a/go/wrapper/session.go b/go/wrapper/session.go index b653b491ebf..21c9f3419b0 100644 --- a/go/wrapper/session.go +++ b/go/wrapper/session.go @@ -73,6 +73,68 @@ func (s *Session) Bandwidth() *Bandwidth { return &Bandwidth{inner: s.inner.Bandwidth()} } +// Auth is the session's authorization: the grant its peer gave it, and more +// tokens to present. A client session's handle survives reconnects, presenting +// each added token again on every new connection. +func (s *Session) Auth() *Auth { + return &Auth{inner: s.inner.Auth()} +} + +// Auth reads a session's grant and presents more tokens. +// +// AUTH is only on moq-lite-07-wip and on moq-transport sessions that negotiate +// the MoQ Auth extension; elsewhere the grant stays nil. +type Auth struct { + inner *ffi.MoqAuth +} + +// Grant returns the union of every token's grant, or nil until the peer first +// answers one. A client session reads nil while it reconnects. +func (a *Auth) Grant() *Grant { + return grantFromFFI(a.inner.Grant()) +} + +// GrantChanged blocks until the grant differs from the one this handle last +// reported, and returns it. It errors once the session ends. +func (a *Auth) GrantChanged(ctx context.Context) (*Grant, error) { + grant, err := bridge.Call(ctx, nil, a.inner.GrantChanged) + return grantFromFFI(grant), err +} + +// Add presents another token, returning once the peer accepts it. Its grant +// joins the union until the token is closed. The peer's refusal is a +// ProtocolError carrying its code; a session that cannot carry another token +// returns an error for which errors.Is(err, ErrUnsupported) holds. Cancelling ctx +// withdraws the token. +func (a *Auth) Add(ctx context.Context, token string) (*AuthToken, error) { + inner, err := bridge.CallHandle(ctx, nil, func(ctx context.Context) (*ffi.MoqAuthToken, error) { + return a.inner.Add(ctx, token) + }) + if err != nil { + return nil, err + } + return &AuthToken{inner: inner}, nil +} + +// AuthToken is one token presented with Auth.Add. +type AuthToken struct { + inner *ffi.MoqAuthToken +} + +// Grant returns this token's own grant, or nil after it ended. +func (t *AuthToken) Grant() *Grant { + return grantFromFFI(t.inner.Grant()) +} + +// Closed blocks until the token ends, returning the peer's revocation or the +// session's close, or nil when the peer closed it without an error. +func (t *AuthToken) Closed(ctx context.Context) error { + return bridge.CallErr(ctx, nil, t.inner.Closed) +} + +// Close withdraws the token, removing its grant from the union. +func (t *AuthToken) Close() { t.inner.Cancel() } + // Bandwidth divides one connection's send estimate among the tracks sharing it. type Bandwidth struct { inner *ffi.MoqBandwidth diff --git a/py/moq-rs/moq/__init__.py b/py/moq-rs/moq/__init__.py index f564c37f84c..1fb96899186 100644 --- a/py/moq-rs/moq/__init__.py +++ b/py/moq-rs/moq/__init__.py @@ -41,7 +41,7 @@ TrackRequest, ) from .server import Request, Server, Transport -from .session import Bandwidth, Reservation, Session +from .session import Auth, AuthToken, Bandwidth, Reservation, Session from .subscribe import ( BroadcastConsumer, GroupConsumer, @@ -59,6 +59,7 @@ ErrorScope, FetchGroupOptions, Frame, + Grant, Order, ProtocolError, ProtocolKind, @@ -87,6 +88,8 @@ "AudioCodec", "AudioEncoderInput", "AudioSampleFormat", + "Auth", + "AuthToken", "Backoff", "Bandwidth", "BroadcastConsumer", @@ -104,6 +107,7 @@ "ProtocolError", "ProtocolKind", "FetchGroupOptions", + "Grant", "GroupConsumer", "GroupDemand", "GroupRequest", diff --git a/py/moq-rs/moq/_records.py b/py/moq-rs/moq/_records.py index a065d3f7255..6d8bbcd0c9b 100644 --- a/py/moq-rs/moq/_records.py +++ b/py/moq-rs/moq/_records.py @@ -16,6 +16,7 @@ MoqConnectionStats, MoqDatagram, MoqFrame, + MoqGrant, MoqOrder, MoqRange, MoqSubscription, @@ -206,3 +207,28 @@ def _from_ffi(stats: MoqConnectionStats) -> ConnectionStats: packets_lost=stats.packets_lost, version=stats.version, ) + + +@dataclass(frozen=True) +class Grant: + """What the peer lets this side do, in this side's own paths. + + An empty pattern list grants nothing in that direction; ``**`` grants everything. + """ + + publish: tuple[str, ...] + """The broadcast patterns this side may publish to the peer.""" + subscribe: tuple[str, ...] + """The broadcast patterns this side may subscribe to from the peer.""" + expires: timedelta | None = None + """How long until the grant lapses, measured when it was read, or ``None`` for never.""" + + @staticmethod + def _from_ffi(grant: MoqGrant | None) -> Grant | None: + if grant is None: + return None + return Grant( + publish=tuple(grant.publish), + subscribe=tuple(grant.subscribe), + expires=_opt_from_us(grant.expires_us), + ) diff --git a/py/moq-rs/moq/session.py b/py/moq-rs/moq/session.py index 465e759b047..f952e6bcb60 100644 --- a/py/moq-rs/moq/session.py +++ b/py/moq-rs/moq/session.py @@ -2,11 +2,11 @@ from __future__ import annotations -from moq_ffi import MoqBandwidth, MoqBandwidthConsumer, MoqError, MoqReservation, MoqSession +from moq_ffi import MoqAuth, MoqAuthToken, MoqBandwidth, MoqBandwidthConsumer, MoqError, MoqReservation, MoqSession from .origin import OriginConsumer, OriginProducer from .publish import TrackDemand -from .types import ConnectionStats, ConnectionStatus +from .types import ConnectionStats, ConnectionStatus, Grant class Session: @@ -107,6 +107,13 @@ def stats(self) -> ConnectionStats: Individual fields are ``None`` when the transport backend doesn't report them.""" return ConnectionStats._from_ffi(self._inner.stats()) + def auth(self) -> Auth: + """The session's authorization: the grant its peer gave it, and more tokens to present. + + A client session's handle survives reconnects, presenting each added token + again on every new connection.""" + return Auth(self._inner.auth()) + def recv_bandwidth(self) -> Bandwidth.Consumer | None: """Hold an owned receive-estimate consumer; releasing the last one stops PROBE.""" inner = self._inner.recv_bandwidth() @@ -123,6 +130,60 @@ def bandwidth(self) -> Bandwidth: return Bandwidth(self._inner.bandwidth()) +class Auth: + """A session's authorization, returned by :meth:`Session.auth`. + + AUTH is only on ``moq-lite-07-wip`` and on moq-transport sessions that negotiate + the MoQ Auth extension; elsewhere the grant stays ``None``:: + + auth = session.auth() + print(auth.grant()) + token = await auth.add(fresh_jwt) + """ + + def __init__(self, inner: MoqAuth) -> None: + self._inner = inner + + def grant(self) -> Grant | None: + """The union of every token's grant, or ``None`` until the peer first answers one. + + A client session reads ``None`` while it reconnects.""" + return Grant._from_ffi(self._inner.grant()) + + async def grant_changed(self) -> Grant | None: + """Wait for the grant to differ from the one last reported, returning it. + + Raises once nothing will change it again, because the session ended.""" + return Grant._from_ffi(await self._inner.grant_changed()) + + async def add(self, token: str) -> AuthToken: + """Present another token, returning once the peer accepts it. + + Its grant joins the union until :meth:`AuthToken.cancel`. The peer's refusal + raises ``moq.Error`` carrying its code; a session that cannot carry another + token raises ``moq.Error.Unsupported``.""" + return AuthToken(await self._inner.add(token)) + + +class AuthToken: + """One token presented with :meth:`Auth.add`, withdrawn by :meth:`cancel`.""" + + def __init__(self, inner: MoqAuthToken) -> None: + self._inner = inner + + def grant(self) -> Grant | None: + """This token's own grant, or ``None`` after it ended.""" + return Grant._from_ffi(self._inner.grant()) + + async def closed(self) -> None: + """Wait for the token to end, raising the peer's revocation or the session's close.""" + await self._inner.closed() + + def cancel(self) -> None: + """Withdraw the token, removing its grant from the union.""" + self._inner.cancel() + + class Bandwidth: """Divides one connection's send estimate among the tracks sharing it. diff --git a/py/moq-rs/moq/types.py b/py/moq-rs/moq/types.py index e9d3a3d237d..262b69d98ee 100644 --- a/py/moq-rs/moq/types.py +++ b/py/moq-rs/moq/types.py @@ -64,7 +64,7 @@ MoqVideoPixelFormat as VideoPixelFormat, ) -from ._records import Backoff, ConnectionStats, Datagram, Frame, Subscription, TrackInfo +from ._records import Backoff, ConnectionStats, Datagram, Frame, Grant, Subscription, TrackInfo __all__ = [ "Anchor", @@ -79,6 +79,7 @@ "ErrorScope", "Frame", "FetchGroupOptions", + "Grant", "Order", "ProtocolError", "ProtocolKind", diff --git a/py/moq-rs/tests/test_server.py b/py/moq-rs/tests/test_server.py index 6339057abb1..9728228f940 100644 --- a/py/moq-rs/tests/test_server.py +++ b/py/moq-rs/tests/test_server.py @@ -507,3 +507,48 @@ async def drain(reader: moq.TrackConsumer) -> None: await asyncio.gather(*reading, return_exceptions=True) if broadcast is not None: broadcast.close() + + +async def _await_grant(auth: moq.Auth, want: moq.Grant) -> None: + async def follow() -> None: + while auth.grant() != want: + await auth.grant_changed() + + await asyncio.wait_for(follow(), timeout=5.0) + + +async def test_session_auth_reads_the_grant(): + """Both sides read the grant their peer gave them; a peer that verifies no tokens refuses one.""" + version = ["moq-lite-07-wip"] + async with moq.Server("127.0.0.1:0", tls_generate=["localhost"], versions=version) as server: + sessions: list = [] + accepted = asyncio.Event() + + async def accept_loop() -> None: + async for request in server: + sessions.append(await request.accept()) + accepted.set() + + accept_task = asyncio.create_task(accept_loop()) + try: + async with moq.Client( + f"https://{server.local_addr}", tls_verify=False, bind="127.0.0.1:0", versions=version + ) as client: + session = client.session + assert session is not None + everything = moq.Grant(publish=("**",), subscribe=("**",)) + await _await_grant(session.auth(), everything) + await asyncio.wait_for(accepted.wait(), timeout=5.0) + await _await_grant(sessions[0].auth(), everything) + + with pytest.raises(moq.Error.Unsupported): + await asyncio.wait_for(session.auth().add("extra"), timeout=5.0) + assert session.auth().grant() == everything + finally: + accept_task.cancel() + try: + await accept_task + except asyncio.CancelledError: + pass + for session in sessions: + session.cancel(0) From 8418edb9fcd54bbfb51b692428e890b22ae81f21 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 20 Oct 2026 18:12:05 -0700 Subject: [PATCH 14/45] feat(kt): alias session auth and follow its grant as a Flow Co-Authored-By: Claude Opus 5.5 --- .../kotlin/dev/moq/Aliases.kt | 6 ++++ .../kotlin/dev/moq/Durations.kt | 4 +++ .../jvmAndAndroidMain/kotlin/dev/moq/Flows.kt | 20 +++++++++++++ .../jvmAndAndroidMain/kotlin/dev/moq/Moq.kt | 8 +++++ .../kotlin/dev/moq/SmokeTest.kt | 30 +++++++++++++++++++ 5 files changed, 68 insertions(+) diff --git a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt index 87c02cff362..c9670629281 100644 --- a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt +++ b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt @@ -151,6 +151,12 @@ typealias BandwidthConsumer = uniffi.moq.MoqBandwidthConsumer typealias Reservation = uniffi.moq.MoqReservation /** A snapshot of transport connection statistics. */ typealias ConnectionStats = uniffi.moq.MoqConnectionStats +/** A session's authorization: the grant its peer gave it, and more tokens to present. */ +typealias Auth = uniffi.moq.MoqAuth +/** One token presented with [Auth.add]; cancel it to withdraw the token. */ +typealias AuthToken = uniffi.moq.MoqAuthToken +/** What the peer lets this side do: publish and subscribe patterns, and an expiry. */ +typealias Grant = uniffi.moq.MoqGrant /** A connection lifecycle transition reported by [Session.status]. */ typealias ConnectionStatus = uniffi.moq.MoqConnectionStatus /** Retry pacing for the automatic reconnect: initial delay, multiplier, ceiling, and give-up window. */ diff --git a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Durations.kt b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Durations.kt index 6dd59ce6e05..d771680811a 100644 --- a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Durations.kt +++ b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Durations.kt @@ -13,6 +13,10 @@ import kotlin.time.Duration.Companion.microseconds val ConnectionStats.rtt: Duration? get() = rttUs?.toLong()?.microseconds +/** How long until the grant lapses, measured when it was read, or null for never. */ +val Grant.expires: Duration? + get() = expiresUs?.toLong()?.microseconds + /** Delay before the first reconnect attempt, or null for the default. */ val Backoff.initial: Duration? get() = initialUs?.toLong()?.microseconds diff --git a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Flows.kt b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Flows.kt index 5dc5e412567..8521351ab68 100644 --- a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Flows.kt +++ b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Flows.kt @@ -4,6 +4,7 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.onCompletion import uniffi.moq.MoqException @@ -55,6 +56,25 @@ fun GroupConsumer.frames(): Flow = pull({ cancel() }) { readFrame() } */ fun AnnounceConsumer.updates(): Flow = pull { next() }.onCompletion { cancel() } +/** + * The session's grant: the current one, then each change, null while there is none. + * + * Completes when the session shuts down, and throws its close reason otherwise. + */ +fun Auth.grants(): Flow = flow { + emit(grant()) + while (true) { + currentCoroutineContext().ensureActive() + val grant = try { + grantChanged() + } catch (e: MoqException) { + if (e.isShutdown) break + throw e + } + emit(grant) + } +}.distinctUntilChanged() + /** Emits [next] until it returns null, checking for cancellation before each pull. */ private fun pull(next: suspend () -> T?): Flow = flow { while (true) { diff --git a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Moq.kt b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Moq.kt index 21d7628e621..22af44a7a77 100644 --- a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Moq.kt +++ b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Moq.kt @@ -68,6 +68,14 @@ class Moq internal constructor( */ fun bandwidth(): Bandwidth = session.bandwidth() + /** + * The session's authorization: the grant its peer gave it, and more tokens to present. + * + * Collect [Auth.grants] to follow the grant, and [Auth.add] a fresh token before the + * current one expires; each added token is presented again on every reconnect. + */ + fun auth(): Auth = session.auth() + /** * Drain finished tracks within one second, throwing on failure. * Explicit `moq-lite-07-wip` waits for the peer to receive finished tails; older diff --git a/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt b/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt index 691a8799972..a94059c54e8 100644 --- a/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt +++ b/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt @@ -619,6 +619,36 @@ class SmokeTest { } } + /** + * Both sides read the grant their peer gave them, and a peer that verifies no tokens + * of its own refuses another one as unsupported. + */ + @Test + fun `session auth reads the grant`() = runTest { + val version = listOf("moq-lite-07-wip") + Server.listen( + ServerConfig(bind = "127.0.0.1:0", versions = version, tls = ServerTls(generate = listOf("localhost"))), + ).use { server -> + // Not requests().first(): ending that collection cancels the server. + val accepted = async { server.server.accept()!!.accept() } + val moq = Moq.connect( + "https://${server.localAddr}", + ClientConfig(bind = "127.0.0.1:0", versions = version, tls = ClientTls(insecure = true), once = true), + ) + moq.use { + val everything = Grant(publish = listOf("**"), subscribe = listOf("**"), expiresUs = null) + assertEquals(everything, moq.auth().grants().first { it != null }) + val session = accepted.await() + assertEquals(everything, session.auth().grants().first { it != null }) + assertNull(everything.expires) + + assertFailsWith { moq.auth().add("extra") } + assertEquals(everything, moq.auth().grant()) + session.cancel(0u) + } + } + } + /** * Cancelling the client aborts a connect parked on a server that never * accepts, and the connect reports it as [MoqException.Cancelled]. From 099b9fa7b1fa0b91df73444996ca67f4fdd3615c Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 20 Oct 2026 18:18:15 -0700 Subject: [PATCH 15/45] feat(dart): alias session auth and stream its grant Co-Authored-By: Claude Opus 5.5 --- dart/moq/lib/src/aliases.dart | 9 + dart/moq/lib/src/client.dart | 27 ++ dart/moq/lib/src/durations.dart | 7 + dart/moq/test/moq_test.dart | 62 +++++ dart/moq_ffi/lib/src/moq.dart | 446 +++++++++++++++++++++++++++++++- 5 files changed, 549 insertions(+), 2 deletions(-) diff --git a/dart/moq/lib/src/aliases.dart b/dart/moq/lib/src/aliases.dart index a72a77f225e..f6c286adc16 100644 --- a/dart/moq/lib/src/aliases.dart +++ b/dart/moq/lib/src/aliases.dart @@ -144,6 +144,15 @@ typedef Reservation = MoqReservation; /// A snapshot of transport connection statistics. typedef ConnectionStats = MoqConnectionStats; +/// A session's authorization: the grant its peer gave it, and more tokens to present. +typedef Auth = MoqAuth; + +/// One token presented with [Auth.add]; cancel it to withdraw the token. +typedef AuthToken = MoqAuthToken; + +/// What the peer lets this side do: publish and subscribe patterns, and an expiry. +typedef Grant = MoqGrant; + /// A connection lifecycle transition reported by [Session.status]. typedef ConnectionStatus = MoqConnectionStatus; diff --git a/dart/moq/lib/src/client.dart b/dart/moq/lib/src/client.dart index a95ecbf5b2e..55fcbc2c1de 100644 --- a/dart/moq/lib/src/client.dart +++ b/dart/moq/lib/src/client.dart @@ -254,6 +254,13 @@ final class Moq { /// the room back. Bandwidth bandwidth() => session.bandwidth(); + /// The session's authorization: the grant its peer gave it, and more tokens to present. + /// + /// Listen to [AuthGrants.grants] to follow the grant, and [Auth.add] a fresh + /// token before the current one expires; each added token is presented again + /// on every reconnect. + Auth auth() => session.auth(); + /// Drain within one second, then stop the client. /// /// Explicit `moq-lite-07-wip` waits for the peer to receive finished tails; older @@ -268,6 +275,26 @@ final class Moq { } } +/// A [Stream] view over a session's grant. +extension AuthGrants on Auth { + /// The current grant, then each change, null while there is none. + /// + /// Ends when the session shuts down, and errors with its close reason otherwise. + Stream grants() async* { + yield grant(); + while (true) { + final Grant? next; + try { + next = await grantChanged(); + } on MoqException catch (e) { + if (e is CancelledMoqException || e is ClosedMoqException) return; + rethrow; + } + yield next; + } + } +} + /// A [Stream] view over an announcement cursor. extension AnnounceConsumerUpdates on AnnounceConsumer { /// Stream announce events until the cursor ends. diff --git a/dart/moq/lib/src/durations.dart b/dart/moq/lib/src/durations.dart index ca766268bbc..d5786188a44 100644 --- a/dart/moq/lib/src/durations.dart +++ b/dart/moq/lib/src/durations.dart @@ -11,6 +11,13 @@ extension ConnectionStatsDuration on MoqConnectionStats { Duration? get rtt => rttUs == null ? null : Duration(microseconds: rttUs!); } +/// Duration views over an authorization grant. +extension GrantDuration on MoqGrant { + /// How long until the grant lapses, measured when it was read, or null for never. + Duration? get expires => + expiresUs == null ? null : Duration(microseconds: expiresUs!); +} + /// Duration views over the reconnect pacing. extension BackoffDuration on MoqBackoff { /// Delay before the first reconnect attempt, or null for the default. diff --git a/dart/moq/test/moq_test.dart b/dart/moq/test/moq_test.dart index 3bcdcfa02fb..dd942ffdab0 100644 --- a/dart/moq/test/moq_test.dart +++ b/dart/moq/test/moq_test.dart @@ -139,6 +139,68 @@ void main() { }, ); + test('both sides read the grant their peer gave them', () async { + const version = ['moq-lite-07-wip']; + final server = MoqServer( + config: MoqServerConfig( + bind: '127.0.0.1:0', + versions: version, + tls: MoqServerTls(generate: ['localhost']), + quic: MoqQuicConfig(), + ), + ); + final address = await server.listen().timeout(timeout); + final accepted = () async { + final request = await server.accept().timeout(timeout); + if (request == null) throw StateError('server closed before accept'); + return request.accept().timeout(timeout); + }(); + + final client = await Moq.connect( + 'https://$address', + options: const ConnectOptions( + tlsVerify: false, + bind: '127.0.0.1:0', + versions: version, + reconnect: false, + ), + ).timeout(timeout); + final serverSession = await accepted; + + // Generated records compare by identity, so compare the fields. + void expectEverything(Grant? grant) { + expect(grant?.publish, ['**']); + expect(grant?.subscribe, ['**']); + expect(grant?.expires, isNull); + } + + expectEverything( + await client + .auth() + .grants() + .firstWhere((g) => g != null) + .timeout(timeout), + ); + expectEverything( + await serverSession + .auth() + .grants() + .firstWhere((g) => g != null) + .timeout(timeout), + ); + + // A peer that verifies no tokens of its own refuses another one. + await expectLater( + client.auth().add(token: 'extra').timeout(timeout), + throwsA(isA()), + ); + expectEverything(client.auth().grant()); + + serverSession.cancel(code: 0); + await client.close(); + server.cancel(); + }); + test('connects, announces, subscribes, and delivers a frame', () async { final relay = MoqOriginProducer(config: MoqOriginConfig()); final server = MoqServer( diff --git a/dart/moq_ffi/lib/src/moq.dart b/dart/moq_ffi/lib/src/moq.dart index 8c807bd0d31..ee8a65e9e23 100644 --- a/dart/moq_ffi/lib/src/moq.dart +++ b/dart/moq_ffi/lib/src/moq.dart @@ -8,9 +8,7 @@ import "dart:ffi"; import "dart:io" show Platform, File, Directory; import "dart:isolate"; import "dart:typed_data"; - import "package:ffi/ffi.dart"; - import "uniffi_runtime.dart"; export "uniffi_runtime.dart"; @@ -497,6 +495,77 @@ class FfiConverterMoqAudioPacket { } } +class MoqGrant { + final List publish; + final List subscribe; + final int? expiresUs; + MoqGrant({required this.publish, required this.subscribe, this.expiresUs}); +} + +class FfiConverterMoqGrant { + static MoqGrant lift(RustBuffer buf) { + return FfiConverterMoqGrant.read(buf.asUint8List()).value; + } + + static LiftRetVal read(Uint8List buf) { + int new_offset = buf.offsetInBytes; + final publish_lifted = FfiConverterSequenceString.read( + Uint8List.view(buf.buffer, new_offset), + ); + final publish = publish_lifted.value; + new_offset += publish_lifted.bytesRead; + final subscribe_lifted = FfiConverterSequenceString.read( + Uint8List.view(buf.buffer, new_offset), + ); + final subscribe = subscribe_lifted.value; + new_offset += subscribe_lifted.bytesRead; + final expiresUs_lifted = FfiConverterOptionalUInt64.read( + Uint8List.view(buf.buffer, new_offset), + ); + final expiresUs = expiresUs_lifted.value; + new_offset += expiresUs_lifted.bytesRead; + return LiftRetVal( + MoqGrant(publish: publish, subscribe: subscribe, expiresUs: expiresUs), + new_offset - buf.offsetInBytes, + ); + } + + static RustBuffer lower(MoqGrant value) { + final total_length = + FfiConverterSequenceString.allocationSize(value.publish) + + FfiConverterSequenceString.allocationSize(value.subscribe) + + FfiConverterOptionalUInt64.allocationSize(value.expiresUs) + + 0; + final buf = Uint8List(total_length); + write(value, buf); + return toRustBuffer(buf); + } + + static int write(MoqGrant value, Uint8List buf) { + int new_offset = buf.offsetInBytes; + new_offset += FfiConverterSequenceString.write( + value.publish, + Uint8List.view(buf.buffer, new_offset), + ); + new_offset += FfiConverterSequenceString.write( + value.subscribe, + Uint8List.view(buf.buffer, new_offset), + ); + new_offset += FfiConverterOptionalUInt64.write( + value.expiresUs, + Uint8List.view(buf.buffer, new_offset), + ); + return new_offset - buf.offsetInBytes; + } + + static int allocationSize(MoqGrant value) { + return FfiConverterSequenceString.allocationSize(value.publish) + + FfiConverterSequenceString.allocationSize(value.subscribe) + + FfiConverterOptionalUInt64.allocationSize(value.expiresUs) + + 0; + } +} + class MoqFetchGroupOptions { final int priority; final int? maxDelayUs; @@ -7744,6 +7813,198 @@ class FfiConverterMoqAudioProducer { } } +abstract class MoqAuthInterface { + Future add({required String token}); + MoqGrant? grant(); + Future grantChanged(); +} + +final _MoqAuthFinalizer = Finalizer>((ptr) { + rustCall((status) => uniffi_moq_ffi_fn_free_moqauth(ptr, status)); +}); + +class MoqAuth implements MoqAuthInterface { + late final Pointer _ptr; + bool _isDisposed = false; + MoqAuth._(this._ptr) { + _MoqAuthFinalizer.attach(this, _ptr, detach: this); + } + factory MoqAuth.lift(Pointer ptr) { + return MoqAuth._(ptr); + } + Pointer uniffiClonePointer() { + if (_isDisposed) { + throw StateError("Object has already been disposed"); + } + return rustCall((status) => uniffi_moq_ffi_fn_clone_moqauth(_ptr, status)); + } + + void dispose() { + if (_isDisposed) { + return; + } + _isDisposed = true; + _MoqAuthFinalizer.detach(this); + rustCall((status) => uniffi_moq_ffi_fn_free_moqauth(_ptr, status)); + } + + Future add({required String token}) { + return uniffiRustCallAsync( + () => uniffi_moq_ffi_fn_method_moqauth_add( + uniffiClonePointer(), + FfiConverterString.lower(token), + ), + ffi_moq_ffi_rust_future_poll_u64, + ffi_moq_ffi_rust_future_complete_u64, + ffi_moq_ffi_rust_future_free_u64, + (ptr) => FfiConverterMoqAuthToken.lift(Pointer.fromAddress(ptr)), + moqExceptionErrorHandler, + ); + } + + MoqGrant? grant() { + return rustCallWithLifter( + (status) => + uniffi_moq_ffi_fn_method_moqauth_grant(uniffiClonePointer(), status), + FfiConverterOptionalMoqGrant.lift, + null, + ); + } + + Future grantChanged() { + return uniffiRustCallAsync( + () => + uniffi_moq_ffi_fn_method_moqauth_grant_changed(uniffiClonePointer()), + ffi_moq_ffi_rust_future_poll_rust_buffer, + ffi_moq_ffi_rust_future_complete_rust_buffer, + ffi_moq_ffi_rust_future_free_rust_buffer, + FfiConverterOptionalMoqGrant.lift, + moqExceptionErrorHandler, + ); + } +} + +class FfiConverterMoqAuth { + static MoqAuth lift(Pointer ptr) { + return MoqAuth.lift(ptr); + } + + static Pointer lower(MoqAuth value) { + return value.uniffiClonePointer(); + } + + static int allocationSize(MoqAuth value) { + return 8; + } + + static LiftRetVal read(Uint8List buf) { + final handle = buf.buffer.asByteData(buf.offsetInBytes).getInt64(0); + final pointer = Pointer.fromAddress(handle); + return LiftRetVal(MoqAuth.lift(pointer), 8); + } + + static int write(MoqAuth value, Uint8List buf) { + final handle = lower(value); + buf.buffer.asByteData(buf.offsetInBytes).setInt64(0, handle.address); + return 8; + } +} + +abstract class MoqAuthTokenInterface { + void cancel(); + Future closed(); + MoqGrant? grant(); +} + +final _MoqAuthTokenFinalizer = Finalizer>((ptr) { + rustCall((status) => uniffi_moq_ffi_fn_free_moqauthtoken(ptr, status)); +}); + +class MoqAuthToken implements MoqAuthTokenInterface { + late final Pointer _ptr; + bool _isDisposed = false; + MoqAuthToken._(this._ptr) { + _MoqAuthTokenFinalizer.attach(this, _ptr, detach: this); + } + factory MoqAuthToken.lift(Pointer ptr) { + return MoqAuthToken._(ptr); + } + Pointer uniffiClonePointer() { + if (_isDisposed) { + throw StateError("Object has already been disposed"); + } + return rustCall( + (status) => uniffi_moq_ffi_fn_clone_moqauthtoken(_ptr, status), + ); + } + + void dispose() { + if (_isDisposed) { + return; + } + _isDisposed = true; + _MoqAuthTokenFinalizer.detach(this); + rustCall((status) => uniffi_moq_ffi_fn_free_moqauthtoken(_ptr, status)); + } + + void cancel() { + return rustCall((status) { + uniffi_moq_ffi_fn_method_moqauthtoken_cancel( + uniffiClonePointer(), + status, + ); + }, null); + } + + Future closed() { + return uniffiRustCallAsync( + () => uniffi_moq_ffi_fn_method_moqauthtoken_closed(uniffiClonePointer()), + ffi_moq_ffi_rust_future_poll_void, + ffi_moq_ffi_rust_future_complete_void, + ffi_moq_ffi_rust_future_free_void, + (_) {}, + moqExceptionErrorHandler, + ); + } + + MoqGrant? grant() { + return rustCallWithLifter( + (status) => uniffi_moq_ffi_fn_method_moqauthtoken_grant( + uniffiClonePointer(), + status, + ), + FfiConverterOptionalMoqGrant.lift, + null, + ); + } +} + +class FfiConverterMoqAuthToken { + static MoqAuthToken lift(Pointer ptr) { + return MoqAuthToken.lift(ptr); + } + + static Pointer lower(MoqAuthToken value) { + return value.uniffiClonePointer(); + } + + static int allocationSize(MoqAuthToken value) { + return 8; + } + + static LiftRetVal read(Uint8List buf) { + final handle = buf.buffer.asByteData(buf.offsetInBytes).getInt64(0); + final pointer = Pointer.fromAddress(handle); + return LiftRetVal(MoqAuthToken.lift(pointer), 8); + } + + static int write(MoqAuthToken value, Uint8List buf) { + final handle = lower(value); + buf.buffer.asByteData(buf.offsetInBytes).setInt64(0, handle.address); + return 8; + } +} + abstract class MoqBandwidthInterface { MoqReservation reserve({required MoqTrackDemand demand, required int maxBps}); } @@ -11994,6 +12255,7 @@ class FfiConverterMoqClient { } abstract class MoqSessionInterface { + MoqAuth auth(); MoqBandwidth bandwidth(); void cancel({required int code}); Future closed(); @@ -12038,6 +12300,17 @@ class MoqSession implements MoqSessionInterface { rustCall((status) => uniffi_moq_ffi_fn_free_moqsession(_ptr, status)); } + MoqAuth auth() { + return rustCallWithLifter( + (status) => uniffi_moq_ffi_fn_method_moqsession_auth( + uniffiClonePointer(), + status, + ), + FfiConverterMoqAuth.lift, + null, + ); + } + MoqBandwidth bandwidth() { return rustCallWithLifter( (status) => uniffi_moq_ffi_fn_method_moqsession_bandwidth( @@ -13625,6 +13898,51 @@ class FfiConverterOptionalMoqFrame { } } +class FfiConverterOptionalMoqGrant { + static MoqGrant? lift(RustBuffer buf) { + return FfiConverterOptionalMoqGrant.read(buf.asUint8List()).value; + } + + static LiftRetVal read(Uint8List buf) { + if (ByteData.view(buf.buffer, buf.offsetInBytes).getInt8(0) == 0) { + return LiftRetVal(null, 1); + } + final result = FfiConverterMoqGrant.read( + Uint8List.view(buf.buffer, buf.offsetInBytes + 1), + ); + return LiftRetVal(result.value, result.bytesRead + 1); + } + + static int allocationSize([MoqGrant? value]) { + if (value == null) { + return 1; + } + return FfiConverterMoqGrant.allocationSize(value) + 1; + } + + static RustBuffer lower(MoqGrant? value) { + if (value == null) { + return toRustBuffer(Uint8List.fromList([0])); + } + final buf = Uint8List(FfiConverterOptionalMoqGrant.allocationSize(value)); + FfiConverterOptionalMoqGrant.write(value, buf); + return toRustBuffer(buf); + } + + static int write(MoqGrant? value, Uint8List buf) { + if (value == null) { + buf[0] = 0; + return 1; + } + buf[0] = 1; + return FfiConverterMoqGrant.write( + value, + Uint8List.view(buf.buffer, buf.offsetInBytes + 1), + ) + + 1; + } +} + class FfiConverterOptionalMoqGroupConsumer { static MoqGroupConsumer? lift(RustBuffer buf) { return FfiConverterOptionalMoqGroupConsumer.read(buf.asUint8List()).value; @@ -15214,6 +15532,80 @@ external void uniffi_moq_ffi_fn_method_moqaudioproducer_write( Pointer uniffiStatus, ); +@Native Function(Pointer, Pointer)>( + assetId: _uniffiAssetId, +) +external Pointer uniffi_moq_ffi_fn_clone_moqauth( + Pointer handle, + Pointer uniffiStatus, +); + +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external void uniffi_moq_ffi_fn_free_moqauth( + Pointer handle, + Pointer uniffiStatus, +); + +@Native Function(Pointer, RustBuffer)>( + assetId: _uniffiAssetId, +) +external Pointer uniffi_moq_ffi_fn_method_moqauth_add( + Pointer ptr, + RustBuffer token, +); + +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external RustBuffer uniffi_moq_ffi_fn_method_moqauth_grant( + Pointer ptr, + Pointer uniffiStatus, +); + +@Native Function(Pointer)>(assetId: _uniffiAssetId) +external Pointer uniffi_moq_ffi_fn_method_moqauth_grant_changed( + Pointer ptr, +); + +@Native Function(Pointer, Pointer)>( + assetId: _uniffiAssetId, +) +external Pointer uniffi_moq_ffi_fn_clone_moqauthtoken( + Pointer handle, + Pointer uniffiStatus, +); + +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external void uniffi_moq_ffi_fn_free_moqauthtoken( + Pointer handle, + Pointer uniffiStatus, +); + +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external void uniffi_moq_ffi_fn_method_moqauthtoken_cancel( + Pointer ptr, + Pointer uniffiStatus, +); + +@Native Function(Pointer)>(assetId: _uniffiAssetId) +external Pointer uniffi_moq_ffi_fn_method_moqauthtoken_closed( + Pointer ptr, +); + +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external RustBuffer uniffi_moq_ffi_fn_method_moqauthtoken_grant( + Pointer ptr, + Pointer uniffiStatus, +); + @Native Function(Pointer, Pointer)>( assetId: _uniffiAssetId, ) @@ -17101,6 +17493,14 @@ external void uniffi_moq_ffi_fn_free_moqsession( Pointer uniffiStatus, ); +@Native Function(Pointer, Pointer)>( + assetId: _uniffiAssetId, +) +external Pointer uniffi_moq_ffi_fn_method_moqsession_auth( + Pointer ptr, + Pointer uniffiStatus, +); + @Native Function(Pointer, Pointer)>( assetId: _uniffiAssetId, ) @@ -17852,6 +18252,24 @@ external int uniffi_moq_ffi_checksum_method_moqaudioproducer_reservation(); @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqaudioproducer_write(); +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqauth_add(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqauth_grant(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqauth_grant_changed(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqauthtoken_cancel(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqauthtoken_closed(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqauthtoken_grant(); + @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqbandwidth_reserve(); @@ -18255,6 +18673,9 @@ external int uniffi_moq_ffi_checksum_method_moqclient_cancel(); @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqclient_connect(); +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqsession_auth(); + @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqsession_bandwidth(); @@ -18501,6 +18922,24 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_method_moqaudioproducer_write() != 22094) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } + if (uniffi_moq_ffi_checksum_method_moqauth_add() != 43924) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqauth_grant() != 14588) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqauth_grant_changed() != 50699) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqauthtoken_cancel() != 59324) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqauthtoken_closed() != 31020) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqauthtoken_grant() != 31654) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } if (uniffi_moq_ffi_checksum_method_moqbandwidth_reserve() != 27807) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } @@ -18923,6 +19362,9 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_method_moqclient_connect() != 18055) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } + if (uniffi_moq_ffi_checksum_method_moqsession_auth() != 18740) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } if (uniffi_moq_ffi_checksum_method_moqsession_bandwidth() != 8006) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } From 7368870d82419f63b6b0fb7207a97b990586c705 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 20 Oct 2026 18:19:37 -0700 Subject: [PATCH 16/45] feat(swift): wrap session auth Co-Authored-By: Claude Opus 5.5 --- swift/Sources/Moq/Auth.swift | 51 +++++++++++++++++++++++++++ swift/Sources/Moq/Client.swift | 8 +++++ swift/Tests/MoqTests/SmokeTests.swift | 33 +++++++++++++++++ 3 files changed, 92 insertions(+) create mode 100644 swift/Sources/Moq/Auth.swift diff --git a/swift/Sources/Moq/Auth.swift b/swift/Sources/Moq/Auth.swift new file mode 100644 index 00000000000..11a2d6700e0 --- /dev/null +++ b/swift/Sources/Moq/Auth.swift @@ -0,0 +1,51 @@ +import MoqFFI + +/// What the peer lets this side do, in this side's own paths: the broadcast +/// patterns it may publish and subscribe to, and `expiresUs` until it lapses. +/// An empty pattern list grants nothing in that direction; `**` grants everything. +public typealias Grant = MoqFFI.MoqGrant + +/// A session's authorization: the grant its peer gave it, and more tokens to present. +/// +/// Returned by `Session.auth()`. AUTH is only on `moq-lite-07-wip` and on +/// moq-transport sessions that negotiate the MoQ Auth extension; elsewhere the +/// grant stays `nil`. +public final class Auth: Sendable { + let ffi: MoqAuth + + init(_ ffi: MoqAuth) { self.ffi = ffi } + + /// The union of every token's grant, or `nil` until the peer first answers one. + /// A client session reads `nil` while it reconnects. + public func grant() -> Grant? { ffi.grant() } + + /// Suspend until the grant differs from the one last reported, returning it. + /// Throws once nothing will change it again, because the session ended. + public func grantChanged() async throws -> Grant? { try await ffi.grantChanged() } + + /// Present another token, returning once the peer accepts it. + /// + /// Its grant joins the union until `AuthToken.cancel()`; a client session presents + /// it again on every reconnect. The peer's refusal throws `MoqError.Protocol` + /// carrying its code, and a session that cannot carry another token throws + /// `MoqError.Unsupported`. + public func add(token: String) async throws -> AuthToken { + AuthToken(try await ffi.add(token: token)) + } +} + +/// One token presented with `Auth.add(token:)`, withdrawn by `cancel()`. +public final class AuthToken: Sendable { + let ffi: MoqAuthToken + + init(_ ffi: MoqAuthToken) { self.ffi = ffi } + + /// This token's own grant, or `nil` after it ended. + public func grant() -> Grant? { ffi.grant() } + + /// Suspend until the token ends, throwing the peer's revocation or the session's close. + public func closed() async throws { try await ffi.closed() } + + /// Withdraw the token, removing its grant from the union. + public func cancel() { ffi.cancel() } +} diff --git a/swift/Sources/Moq/Client.swift b/swift/Sources/Moq/Client.swift index 509aa0741cf..c13ee718e69 100644 --- a/swift/Sources/Moq/Client.swift +++ b/swift/Sources/Moq/Client.swift @@ -156,6 +156,14 @@ public final class Session: Sendable { ffi.stats() } + /// The session's authorization: the grant its peer gave it, and more tokens to present. + /// + /// A client session's handle survives reconnects, presenting each added token + /// again on every new connection. + public func auth() -> Auth { + Auth(ffi.auth()) + } + /// Hold receive-estimate demand until the returned consumer is released. public func recvBandwidth() -> Bandwidth.Consumer? { ffi.recvBandwidth().map(Bandwidth.Consumer.init) diff --git a/swift/Tests/MoqTests/SmokeTests.swift b/swift/Tests/MoqTests/SmokeTests.swift index d34f06f0f51..780e04fdc56 100644 --- a/swift/Tests/MoqTests/SmokeTests.swift +++ b/swift/Tests/MoqTests/SmokeTests.swift @@ -101,6 +101,39 @@ final class SmokeTests: XCTestCase { } } + /// Both sides read the grant their peer gave them, and a peer that verifies no + /// tokens of its own refuses another one as unsupported. + func testSessionAuthReadsTheGrant() async throws { + let version = ["moq-lite-07-wip"] + let server = try Server(bind: "127.0.0.1:0", versions: version, tls: ServerTls(generate: ["localhost"])) + defer { server.cancel() } + let addr = try await server.listen() + async let accepted = server.accept() + + let client = try Client(bind: "127.0.0.1:0", versions: version, tls: ClientTls(insecure: true), once: true) + defer { client.cancel() } + let session = try await client.connect(to: "https://\(addr)") + guard let request = try await accepted else { return XCTFail("server closed before accept") } + let peer = try await request.accept() + + let everything = Grant(publish: ["**"], subscribe: ["**"], expiresUs: nil) + for auth in [session.auth(), peer.auth()] { + var grant = auth.grant() + while grant != everything { + grant = try await auth.grantChanged() + } + } + + do { + _ = try await session.auth().add(token: "extra") + XCTFail("the default acceptor refuses a token") + } catch MoqError.Unsupported {} + XCTAssertEqual(session.auth().grant(), everything) + + peer.cancel(code: 0) + session.cancel(code: 0) + } + /// `cancel()` releases the listening socket before it returns, so the same /// address binds again with no retry. func testServerCloseReleasesPort() async throws { From f196d5b8feda67de0df32dffaa84b72c38995ab9 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 20 Oct 2026 18:30:00 -0700 Subject: [PATCH 17/45] test(interop): binding clients print and enforce their grant Co-Authored-By: Claude Opus 5.5 --- test/interop/README.md | 13 +++--- test/interop/clients/cpp/main.cpp | 52 ++++++++++++++++++---- test/interop/clients/go/main.go | 60 +++++++++++++++++++++++--- test/interop/clients/python/interop.py | 45 ++++++++++++++++--- test/interop/interop.sh | 30 +++++++------ 5 files changed, 159 insertions(+), 41 deletions(-) diff --git a/test/interop/README.md b/test/interop/README.md index bd8c4aa7a8d..df8d6a65a4a 100644 --- a/test/interop/README.md +++ b/test/interop/README.md @@ -104,19 +104,20 @@ subtree and each subscriber with one to read it, so every cell also covers the Clients that print the grant the relay sent back over AUTH, as an `auth granted publish=[...] subscribe=[...]` line, must report exactly what -their token implies: the Rust CLI (a `moq_net::auth` debug log) and the native +their token implies: the Rust CLI (a `moq_net::auth` debug log), the Python, +Go, and C++ clients (read through moq-ffi before they start), and the native JS subscribers. A cell whose grant is missing or wrong fails even when media -flowed. The binding clients (Python, Go, C, GStreamer) have no grant to print -until moq-ffi exposes one, and the browser's shared connection keeps its session -private, so their cells check media alone. AUTH is only on the work-in-progress +flowed. moq-c's frozen ABI and GStreamer expose no grant, and the browser's +shared connection keeps its session private, so their cells check media alone. AUTH is only on the work-in-progress moq-lite-07, so the printing clients dial `moq-lite-07-wip` alone and a printing client that reports nothing never got its grant. The native JS subscribers dial WebTransport alone and offer it there. The rest keep their defaults, so the matrix also crosses versions through the relay, which accepts both. -After the matrix, each publisher whose refusal the harness can read (Rust) runs -once more with a token that excludes its broadcast. It must fail loud, logging +After the matrix, each publisher whose refusal the harness can read (Rust, +Python, Go, and C++, which log moq-net's error) runs once more with a token that +excludes its broadcast. It must fail loud, logging Unauthorized and naming the path, and every subscriber must time out. The browser publisher enforces its grant too, but its elements cannot offer `moq-lite-07-wip` yet. diff --git a/test/interop/clients/cpp/main.cpp b/test/interop/clients/cpp/main.cpp index 29083c62132..2dae8bf83b0 100644 --- a/test/interop/clients/cpp/main.cpp +++ b/test/interop/clients/cpp/main.cpp @@ -9,6 +9,9 @@ // // ffmpeg ... -f h264 - | cpp-interop publish --url http://localhost:4443 --broadcast b.hang // cpp-interop subscribe --url http://localhost:4443 --broadcast b.hang --timeout 20 +// +// --auth-version offers only that version, one that carries AUTH: the client waits for the +// grant the relay sends and prints it as an `auth granted` line before it starts. #include @@ -99,12 +102,38 @@ T until(moq::Future future, Clock::time_point deadline, const char *what) { return ok(future.get(), what); } -// The session owns its connection; the client that made it can go. -std::shared_ptr connect(const std::string &url, Clock::time_point deadline) { +// A JSON list of strings, the shape the Rust client logs its grant's patterns in. +std::string patterns(const std::vector &list) { + std::string out = "["; + for (size_t i = 0; i < list.size(); i++) { + out += (i == 0 ? "\"" : ", \"") + list[i] + "\""; + } + return out + "]"; +} + +// The session owns its connection; the client that made it can go. With an `auth_version`, +// waits for the relay's grant and prints it in the Rust client's `auth granted` shape. +std::shared_ptr connect( + const std::string &url, const std::string &auth_version, Clock::time_point deadline +) { moq::ClientConfig config; config.tls.insecure = true; + if (!auth_version.empty()) { + config.versions = {auth_version}; + } auto client = ok(moq::Client::init(config), "client init"); open_session = until(client->connect(url), deadline, "connect"); + if (!auth_version.empty()) { + auto auth = open_session->auth(); + auto grant = auth->grant(); + while (!grant) { + grant = until(auth->grant_changed(), deadline, "grant"); + } + std::fprintf( + stderr, "auth granted publish=%s subscribe=%s\n", patterns(grant->publish).c_str(), + patterns(grant->subscribe).c_str() + ); + } return open_session; } @@ -135,10 +164,10 @@ void publish_tone(const std::shared_ptr &audio, const std::a } } -int publish(const std::string &url, const std::string &path) { +int publish(const std::string &url, const std::string &path, const std::string &auth_version) { // No deadline to publish: the harness stops the publisher when the round ends. const auto forever = Clock::now() + std::chrono::hours(24); - auto session = connect(url, forever); + auto session = connect(url, auth_version, forever); // Hold the producer for the lifetime of the publish loop; close() unpublishes. auto broadcast = ok(session->publish()->create_broadcast(path), "create_broadcast"); @@ -189,10 +218,10 @@ int publish(const std::string &url, const std::string &path) { return 0; } -int subscribe(const std::string &url, const std::string &path, double timeout) { +int subscribe(const std::string &url, const std::string &path, const std::string &auth_version, double timeout) { const auto deadline = Clock::now() + std::chrono::duration_cast(std::chrono::duration(timeout)); - auto session = connect(url, deadline); + auto session = connect(url, auth_version, deadline); auto announced = ok(session->consume()->announced_broadcast(path), "announced_broadcast"); auto consumer = until(announced->available(), deadline, "available"); @@ -238,11 +267,12 @@ int subscribe(const std::string &url, const std::string &path, double timeout) { int main(int argc, char **argv) { if (argc < 2) { - fail("usage: cpp-interop publish|subscribe --url URL --broadcast PATH [--timeout SECONDS]"); + fail("usage: cpp-interop publish|subscribe --url URL --broadcast PATH [--timeout SECONDS] [--auth-version V]"); } const std::string role = argv[1]; std::string url; std::string broadcast; + std::string auth_version; double timeout = 20; for (int i = 2; i + 1 < argc; i += 2) { const std::string flag = argv[i]; @@ -252,6 +282,8 @@ int main(int argc, char **argv) { broadcast = argv[i + 1]; } else if (flag == "--timeout") { timeout = std::atof(argv[i + 1]); + } else if (flag == "--auth-version") { + auth_version = argv[i + 1]; } else { fail(("unknown flag " + flag).c_str()); } @@ -259,12 +291,14 @@ int main(int argc, char **argv) { if (url.empty() || broadcast.empty()) { fail("--url and --broadcast are required"); } + // Warnings and errors from the Rust layer, such as a publication outside the grant. + ok(moq::moq_log_level("warn"), "log level"); if (role == "publish") { - return publish(url, broadcast); + return publish(url, broadcast, auth_version); } if (role == "subscribe") { - return subscribe(url, broadcast, timeout); + return subscribe(url, broadcast, auth_version, timeout); } fail(("unknown role " + role).c_str()); } diff --git a/test/interop/clients/go/main.go b/test/interop/clients/go/main.go index 1763efd09df..291fb6e686a 100644 --- a/test/interop/clients/go/main.go +++ b/test/interop/clients/go/main.go @@ -10,11 +10,16 @@ // // ffmpeg ... -f h264 - | go-interop publish --url http://localhost:4443 --broadcast b.hang // go-interop subscribe --url http://localhost:4443 --broadcast b.hang --timeout 20 +// +// --auth-version offers only that version, one that carries AUTH: the client waits +// for the grant the relay sends and prints it as an `auth granted` line before it +// starts. package main import ( "context" "encoding/binary" + "encoding/json" "errors" "flag" "fmt" @@ -74,8 +79,43 @@ func publishTone(ctx context.Context, audio *moqaudio.Producer) { } } -func publish(ctx context.Context, url, broadcast string) error { - client, err := moq.Dial(ctx, url, moq.WithTLSVerify(false)) +// dial connects to url. With an authVersion, it offers only that version and waits +// for the relay's grant, printing it in the Rust client's `auth granted` shape. +func dial(ctx context.Context, url, authVersion string, timeout time.Duration) (*moq.Client, error) { + opts := []moq.ClientOption{moq.WithTLSVerify(false)} + if authVersion != "" { + opts = append(opts, moq.WithVersions(authVersion)) + } + client, err := moq.Dial(ctx, url, opts...) + if err != nil || authVersion == "" { + return client, err + } + + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + auth := client.Session().Auth() + grant := auth.Grant() + for grant == nil { + if grant, err = auth.GrantChanged(ctx); err != nil { + client.Close() + return nil, fmt.Errorf("grant: %w", err) + } + } + fmt.Fprintf(os.Stderr, "auth granted publish=%s subscribe=%s\n", patterns(grant.Publish), patterns(grant.Subscribe)) + return client, nil +} + +// patterns renders a grant's patterns as a JSON list, the shape the Rust client logs. +func patterns(list []string) string { + if list == nil { + list = []string{} + } + out, _ := json.Marshal(list) + return string(out) +} + +func publish(ctx context.Context, url, broadcast, authVersion string, timeout time.Duration) error { + client, err := dial(ctx, url, authVersion, timeout) if err != nil { return err } @@ -176,11 +216,11 @@ func catalogWithVideo(ctx context.Context, consumer *moq.BroadcastConsumer) (*mo } } -func subscribe(ctx context.Context, url, broadcast string, timeout time.Duration) error { +func subscribe(ctx context.Context, url, broadcast, authVersion string, timeout time.Duration) error { ctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() - client, err := moq.Dial(ctx, url, moq.WithTLSVerify(false)) + client, err := dial(ctx, url, authVersion, timeout) if err != nil { return err } @@ -234,7 +274,7 @@ func subscribe(ctx context.Context, url, broadcast string, timeout time.Duration func run() error { if len(os.Args) < 2 { - return errors.New("usage: go-interop publish|subscribe --url URL --broadcast PATH [--timeout SECONDS]") + return errors.New("usage: go-interop publish|subscribe --url URL --broadcast PATH [--timeout SECONDS] [--auth-version V]") } role := os.Args[1] @@ -242,6 +282,7 @@ func run() error { url := flags.String("url", "", "relay URL") broadcast := flags.String("broadcast", "", "broadcast path") timeout := flags.Float64("timeout", 20, "seconds to wait for data") + authVersion := flags.String("auth-version", "", "offer only this version, which carries AUTH, and print the grant") if err := flags.Parse(os.Args[2:]); err != nil { return err } @@ -249,12 +290,17 @@ func run() error { return errors.New("--url and --broadcast are required") } + // Warnings and errors from the Rust layer, such as a publication outside the grant. + if err := moq.LogLevel("warn"); err != nil { + return err + } + wait := time.Duration(*timeout * float64(time.Second)) ctx := context.Background() switch role { case "publish": - return publish(ctx, *url, *broadcast) + return publish(ctx, *url, *broadcast, *authVersion, wait) case "subscribe": - return subscribe(ctx, *url, *broadcast, time.Duration(*timeout*float64(time.Second))) + return subscribe(ctx, *url, *broadcast, *authVersion, wait) default: return fmt.Errorf("unknown role %q", role) } diff --git a/test/interop/clients/python/interop.py b/test/interop/clients/python/interop.py index 2433090615d..b8e3ea078cf 100644 --- a/test/interop/clients/python/interop.py +++ b/test/interop/clients/python/interop.py @@ -7,6 +7,9 @@ subscribe: connect, find the video track in the catalog, and exit 0 as soon as any non-empty frame arrives (exit 1 on timeout / no data). +--auth-version offers only that version, one that carries AUTH: the client waits for +the grant the relay sends and prints it as an `auth granted` line before it starts. + ffmpeg ... -f h264 - | python interop.py publish --url http://localhost:4443 --broadcast b.hang python interop.py subscribe --url http://localhost:4443 --broadcast b.hang --timeout 20 """ @@ -14,6 +17,7 @@ import argparse import asyncio import contextlib +import json import math import struct import sys @@ -55,8 +59,33 @@ async def _publish_tone(audio: moq.audio.Producer) -> None: await asyncio.sleep(max(0.0, started + timestamp_us / 1_000_000 - loop.time())) -async def publish(url: str, broadcast: str) -> None: - async with moq.Client(url, tls_verify=False) as client: +def _client(url: str, auth_version: str | None) -> moq.Client: + return moq.Client(url, tls_verify=False, versions=[] if auth_version is None else [auth_version]) + + +async def _print_grant(client: moq.Client, auth_version: str | None, timeout: float) -> None: + """Wait for the relay's grant and print it, in the Rust client's `auth granted` shape.""" + if auth_version is None: + return + session = client.session + assert session is not None + auth = session.auth() + + async def first() -> moq.Grant: + while (grant := auth.grant()) is None: + await auth.grant_changed() + return grant + + grant = await asyncio.wait_for(first(), timeout) + print( + f"auth granted publish={json.dumps(list(grant.publish))} subscribe={json.dumps(list(grant.subscribe))}", + file=sys.stderr, + ) + + +async def publish(url: str, broadcast: str, auth_version: str | None, timeout: float) -> None: + async with _client(url, auth_version) as client: + await _print_grant(client, auth_version, timeout) # Hold the producer for the lifetime of the publish loop; finish() unpublishes. producer = client.create_broadcast(broadcast) catalog = moq.media.CatalogProducer(producer) @@ -108,8 +137,9 @@ async def _catalog_with_video(consumer: moq.BroadcastConsumer) -> moq.media.Cata raise RuntimeError("catalog stream ended without a video track") -async def subscribe(url: str, broadcast: str, timeout: float) -> None: - async with moq.Client(url, tls_verify=False) as client: +async def subscribe(url: str, broadcast: str, auth_version: str | None, timeout: float) -> None: + async with _client(url, auth_version) as client: + await _print_grant(client, auth_version, timeout) consumer = await asyncio.wait_for(client.announced_broadcast(broadcast), timeout) catalog = await asyncio.wait_for(_catalog_with_video(consumer), timeout) @@ -141,13 +171,16 @@ def main() -> None: parser.add_argument("--url", required=True) parser.add_argument("--broadcast", required=True) parser.add_argument("--timeout", type=float, default=20.0) + parser.add_argument("--auth-version") args = parser.parse_args() + # Warnings and errors from the Rust layer, such as a publication outside the grant. + moq.log_level("warn") try: if args.role == "publish": - asyncio.run(publish(args.url, args.broadcast)) + asyncio.run(publish(args.url, args.broadcast, args.auth_version, args.timeout)) else: - asyncio.run(subscribe(args.url, args.broadcast, args.timeout)) + asyncio.run(subscribe(args.url, args.broadcast, args.auth_version, args.timeout)) except KeyboardInterrupt: pass except (TimeoutError, asyncio.TimeoutError): diff --git a/test/interop/interop.sh b/test/interop/interop.sh index c691d545df0..83ed46ecff9 100755 --- a/test/interop/interop.sh +++ b/test/interop/interop.sh @@ -609,12 +609,12 @@ CLI_LOG="${RUST_LOG:-info},moq_net::auth=debug" AUTH_VERSION="${INTEROP_VERSION:-moq-lite-07-wip}" # The clients that print the grant they received as an `auth granted` line. The -# binding clients (python, go, c, gst) have no grant to print until moq-ffi -# exposes one, and the browser's shared connection keeps its session private. +# Python, Go, and C++ clients read it through moq-ffi; moq-c's frozen ABI and +# GStreamer expose none, and the browser's shared connection keeps its session private. prints_grant() { [[ "$COMPAT" -eq 0 ]] || return 1 case "$1" in - rust | js-native-node | js-native-bun) return 0 ;; + rust | python | go | cpp | js-native-node | js-native-bun) return 0 ;; *) return 1 ;; esac } @@ -665,6 +665,9 @@ ffmpeg_h264() { # shellcheck disable=SC2329 # invoked indirectly via 'harness_spawn' run_publisher() { local lang="$1" broadcast="$2" url="$3" + # The binding clients dial $AUTH_VERSION alone and print their grant only when it is checked. + local auth=() + if prints_grant "$lang"; then auth=(--auth-version "$AUTH_VERSION"); fi case "$lang" in rust) # A finished import stays up for its max-age; `stop_publisher` allows 10 s to close. @@ -673,13 +676,13 @@ run_publisher() { ;; python) ffmpeg_h264 | "$PY" "$CLIENTS/python/interop.py" \ - publish --url "$url" --broadcast "$broadcast" + publish --url "$url" --broadcast "$broadcast" "${auth[@]}" ;; go) - ffmpeg_h264 | "$GO_INTEROP" publish --url "$url" --broadcast "$broadcast" + ffmpeg_h264 | "$GO_INTEROP" publish --url "$url" --broadcast "$broadcast" "${auth[@]}" ;; cpp) - ffmpeg_h264 | "$CPP_INTEROP" publish --url "$url" --broadcast "$broadcast" + ffmpeg_h264 | "$CPP_INTEROP" publish --url "$url" --broadcast "$broadcast" "${auth[@]}" ;; js-native) # exec, so the client leads the group and `stop_publisher` waits for its own close. @@ -760,6 +763,8 @@ observe_group() { # shellcheck disable=SC2329 # reached from a function 'harness_spawn' invokes run_subscriber() { local lang="$1" broadcast="$2" url="$3" publisher="${4:-}" + local auth=() + if prints_grant "$lang"; then auth=(--auth-version "$AUTH_VERSION"); fi case "$lang" in rust) # moq-cli only handles SIGINT, so -k forces SIGKILL if it ignores the @@ -820,13 +825,13 @@ PYCODE ;; python) "$PY" "$CLIENTS/python/interop.py" \ - subscribe --url "$url" --broadcast "$broadcast" --timeout "$TIMEOUT" + subscribe --url "$url" --broadcast "$broadcast" --timeout "$TIMEOUT" "${auth[@]}" ;; go) - "$GO_INTEROP" subscribe --url "$url" --broadcast "$broadcast" --timeout "$TIMEOUT" + "$GO_INTEROP" subscribe --url "$url" --broadcast "$broadcast" --timeout "$TIMEOUT" "${auth[@]}" ;; cpp) - "$CPP_INTEROP" subscribe --url "$url" --broadcast "$broadcast" --timeout "$TIMEOUT" + "$CPP_INTEROP" subscribe --url "$url" --broadcast "$broadcast" --timeout "$TIMEOUT" "${auth[@]}" ;; c) "$C_INTEROP" subscribe --url "$url" --broadcast "$broadcast" --timeout "$TIMEOUT" @@ -1117,13 +1122,12 @@ run_media() { fi } -# The publishers whose refusal the harness can read: the Rust CLI's log. The -# binding publishers enforce the grant too, but surface it only through their -# bindings, and the browser elements have no way to offer $AUTH_VERSION. +# The publishers whose refusal the harness can read: each logs moq-net's error. +# The browser elements have no way to offer $AUTH_VERSION. enforces_grant() { [[ "$COMPAT" -eq 0 ]] || return 1 case "$1" in - rust) return 0 ;; + rust | python | go | cpp) return 0 ;; *) return 1 ;; esac } From c168ceab47ec3992f3962b03750ed50ea1430fc0 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 20 Oct 2026 18:31:33 -0700 Subject: [PATCH 18/45] doc(lib): document session auth in every binding Co-Authored-By: Claude Opus 5.5 --- doc/lib/cpp/index.md | 4 ++++ doc/lib/dart/index.md | 1 + doc/lib/go/index.md | 1 + doc/lib/index.md | 3 ++- doc/lib/kt/index.md | 1 + doc/lib/py/index.md | 1 + doc/lib/swift/index.md | 1 + py/moq-rs/README.md | 1 + py/moq-rs/docs/index.md | 3 +++ 9 files changed, 15 insertions(+), 1 deletion(-) diff --git a/doc/lib/cpp/index.md b/doc/lib/cpp/index.md index 876392fbd12..a5888794490 100644 --- a/doc/lib/cpp/index.md +++ b/doc/lib/cpp/index.md @@ -196,6 +196,10 @@ Hold the owned `BandwidthConsumer` returned by `session->recv_bandwidth()` to re Obtain a dynamic track request’s `demand()` before accepting it to watch subscribers through the handoff to its producer. The watch does not keep the track alive and ends when the track closes or the request is rejected. +## Authorization + +On `moq-lite-07-wip` (opt in with `ClientConfig::versions`), `session->auth()->grant()` is the `Grant` the relay sent back, empty until it answers and while a client session reconnects; `grant_changed()` waits for the next one. `add(token)` presents a fresh token without reconnecting and resolves to an `AuthToken` whose grant joins the union until `cancel()`; it is presented again on every reconnect. A refusal resolves to an `Error` carrying the relay's code. + ## Stats `session->stats()` reports the transport counters, each a `std::optional` that is empty when the transport does not report it, plus the negotiated protocol in `version`. While a client session reconnects, `session->failure()` returns why as a `moq::Error`. diff --git a/doc/lib/dart/index.md b/doc/lib/dart/index.md index 73bdd8968c3..814ac2b09b4 100644 --- a/doc/lib/dart/index.md +++ b/doc/lib/dart/index.md @@ -136,6 +136,7 @@ shows which module holds each. - **Decoded frames hold decoder buffers.** `dispose()` each frame from a `video.Consumer` promptly rather than waiting for the garbage collector, or the decoder stalls. `resize` is best effort, so read each frame's `width()` and `height()`. - **Closing.** `await moq.close()` or `session.shutdown()` drains for up to one second and reports any error. `session.cancel(code: 0)` closes at once. Finish or abort live tracks first. Cancelling a stream subscription releases its native cursor. Application delivery requires explicitly selecting `moq-lite-07-wip` on both peers; older Lite versions (including default lite-06) and IETF wait only for transport ACKs. - **Stats.** `session.stats()` reports `rttUs`, `estimatedSendRateBps`, and the byte and packet counters (`bytesSent`, `bytesReceived`, `bytesLost`, `packetsSent`, `packetsReceived`, `packetsLost`). A field is `null` when the transport does not report it, which is not the same as zero. `version` names the negotiated protocol, and while a client session reconnects `session.failure()` says why. Hold the owned `BandwidthConsumer` returned by `session.recvBandwidth()` to receive bandwidth estimates. `peek()` reads the current estimate in bits per second and `changed()` waits for a new one. Release the consumer or call `cancel()` when finished; releasing the last consumer stops PROBE; the session and stats snapshots do not request it. Client consumers survive reconnects and report no estimate while disconnected. +- **Authorization.** On `moq-lite-07-wip` (opt in with `ConnectOptions(versions: ...)`), listen to `moq.auth().grants()` for the `Grant` the relay sent back, `null` until it answers and while a client session reconnects. `await auth().add(token: ...)` presents a fresh token without reconnecting, returns an `AuthToken` whose grant joins the union until `cancel()`, and is presented again on every reconnect. A refusal throws with the relay's code. - **Errors.** Read diagnostic payloads from `reason` and structured protocol failures from `details`. - **Disabled renditions.** Audio and video convenience decoders reject a catalog configuration with `enabled: false`. Select an enabled rendition from the latest catalog before opening a decoder. - **Track demand.** Obtain a dynamic track request’s `demand()` before accepting it to watch subscribers through the handoff to its producer. The watch does not keep the track alive and ends when the track closes or the request is rejected. diff --git a/doc/lib/go/index.md b/doc/lib/go/index.md index debf9f3bf99..7f65e14765e 100644 --- a/doc/lib/go/index.md +++ b/doc/lib/go/index.md @@ -126,6 +126,7 @@ shows which module holds each. - **Decoded frames hold decoder buffers.** `Close` each `VideoDecodedFrame` promptly, or the decoder stalls. `Resize` is best effort, so read each frame's `Width()` and `Height()`. - **Closing.** `client.Close()` drains for up to one second and returns an error on failure. `session.Shutdown(ctx)` does the same, and `session.Cancel(code)` closes at once. Finish or abort live tracks first. Application delivery requires explicitly selecting `moq-lite-07-wip` on both peers; older Lite versions (including default lite-06) and IETF wait only for transport ACKs. - **Stats.** `Session().Stats()` reports `RTT`, `EstimatedSendRateBps`, and the byte and packet counters (`BytesSent`, `BytesReceived`, `BytesLost`, `PacketsSent`, `PacketsReceived`, `PacketsLost`). A field is nil when the transport does not report it, which is not the same as zero. `Version` names the negotiated protocol, and while a client session reconnects `Session().Failure()` says why. Hold the owned `BandwidthConsumer` returned by `Session().RecvBandwidth()` to receive bandwidth estimates. `Peek()` reads the current estimate in bits per second and `Changed(ctx)` waits for a new one. Calling `Close()` on the last consumer stops PROBE; the session and stats snapshots do not request it. Client consumers survive reconnects and report no estimate while disconnected. +- **Authorization.** On `moq-lite-07-wip` (opt in with `moq.WithVersions`), `Session().Auth().Grant()` is the `Grant` the relay sent back, nil until it answers and while a client session reconnects; `GrantChanged(ctx)` waits for the next one. `Add(ctx, token)` presents a fresh token without reconnecting, returns an `AuthToken` whose grant joins the union until `Close()`, and is presented again on every reconnect. A refusal returns the relay's code. - **Errors.** Read diagnostic payloads from `Reason` and structured protocol failures from `Details`. - **Disabled renditions.** Audio and video convenience decoders reject a catalog configuration with `enabled: false`. Select an enabled rendition from the latest catalog before opening a decoder. - **Track demand.** Obtain a dynamic track request’s `Demand()` before accepting it to watch subscribers through the handoff to its producer. The watch does not keep the track alive and ends when the track closes or the request is rejected. diff --git a/doc/lib/index.md b/doc/lib/index.md index 291995ff87d..b9e2df24484 100644 --- a/doc/lib/index.md +++ b/doc/lib/index.md @@ -45,8 +45,9 @@ how it looks in that language: - **Catalog extensions**: write your own section next to `video` and `audio`, and read others' back. - **Routes**: see which relays a broadcast came through, and advertise a cost as a standby publisher. - **Connection stats** and **errors** that tell an auth rejection (don't retry) from a shutdown or a transport failure. +- **Authorization in band**: read the grant the relay sent back (the patterns this side may publish and subscribe to, and when it lapses), and present a fresh token before the current one expires, without reconnecting. It needs `moq-lite-07-wip`, which is opt-in. -C's audio decoder neither takes nor reports a playout delay. +C's audio decoder neither takes nor reports a playout delay, and C has no in-band authorization. ## Upgrading diff --git a/doc/lib/kt/index.md b/doc/lib/kt/index.md index 4c8b59e9c2c..bb41bfc60be 100644 --- a/doc/lib/kt/index.md +++ b/doc/lib/kt/index.md @@ -98,6 +98,7 @@ shows which module holds each. - **Decoded frames hold decoder buffers.** `close()` each frame from a `dev.moq.video.Consumer` promptly (or `use { }` it), or the decoder stalls. `resize` is best effort, so read each frame's `width()` and `height()`. - **Closing.** Suspending `moq.shutdown()` drains for up to one second and throws on failure. `Moq.close()`, and so `use { }`, cancels at once. Finish or abort live tracks first. Application delivery requires explicitly selecting `moq-lite-07-wip` on both peers; older Lite versions (including default lite-06) and IETF wait only for transport ACKs. - **Stats.** `session.stats()` reports `rttUs`, `estimatedSendRateBps`, and the byte and packet counters (`bytesSent`, `bytesReceived`, `bytesLost`, `packetsSent`, `packetsReceived`, `packetsLost`). A field is `null` when the transport does not report it, which is not the same as zero. `version` names the negotiated protocol, and while a client session reconnects `session.failure()` says why. Hold the owned `BandwidthConsumer` returned by `session.recvBandwidth()` to receive bandwidth estimates. `peek()` reads the current estimate in bits per second and `changed()` waits for a new one. Release the consumer or call `cancel()` when finished; releasing the last consumer stops PROBE; the session and stats snapshots do not request it. Client consumers survive reconnects and report no estimate while disconnected. +- **Authorization.** On `moq-lite-07-wip` (opt in with `ClientConfig(versions = ...)`), collect `moq.auth().grants()` for the `Grant` the relay sent back, `null` until it answers and while a client session reconnects. Suspending `auth().add(token)` presents a fresh token without reconnecting, returns an `AuthToken` whose grant joins the union until `cancel()`, and is presented again on every reconnect. A refusal throws with the relay's code. - **Errors.** Read diagnostic payloads from `reason` and structured protocol failures from `details`. - **Disabled renditions.** Audio and video convenience decoders reject a catalog configuration with `enabled: false`. Select an enabled rendition from the latest catalog before opening a decoder. - **Track demand.** Obtain a dynamic track request’s `demand()` before accepting it to watch subscribers through the handoff to its producer. The watch does not keep the track alive and ends when the track closes or the request is rejected. diff --git a/doc/lib/py/index.md b/doc/lib/py/index.md index fd702699d9d..a768bf1c0fa 100644 --- a/doc/lib/py/index.md +++ b/doc/lib/py/index.md @@ -113,6 +113,7 @@ shows which module holds each. - **Decoded frames hold decoder buffers.** Drop each frame from a `moq.video.Consumer` promptly, or the decoder stalls. `resize` is best effort, so read each frame's `width()` and `height()`. - **Closing.** `await session.shutdown()` drains for up to one second and raises on failure. `cancel(code)` closes at once. Finish or abort live tracks first. Application delivery requires explicitly selecting `moq-lite-07-wip` on both peers; older Lite versions (including default lite-06) and IETF wait only for transport ACKs. - **Stats.** `session.stats()` reports `rtt`, `estimated_send_rate_bps`, and the byte and packet counters (`bytes_sent`, `bytes_received`, `bytes_lost`, `packets_sent`, `packets_received`, `packets_lost`). A field is `None` when the transport does not report it, which is not the same as zero. `version` names the negotiated protocol, and while a client session reconnects `session.failure()` says why. Hold the owned `Bandwidth.Consumer` returned by `session.recv_bandwidth()` to receive bandwidth estimates. `peek()` reads the current estimate in bits per second and `changed()` waits for a new one. Release the consumer or call `cancel()` when finished; releasing the last consumer stops PROBE; the session and stats snapshots do not request it. Client consumers survive reconnects and report no estimate while disconnected. +- **Authorization.** On `moq-lite-07-wip` (opt in with `versions=`), `session.auth().grant()` is the `Grant` the relay sent back, `None` until it answers and while a client session reconnects; `await auth.grant_changed()` waits for the next one. `await auth.add(token)` presents a fresh token without reconnecting, returns an `AuthToken` whose grant joins the union until `cancel()`, and is presented again on every reconnect. A refusal raises with the relay's code. - **Errors.** Read diagnostic payloads from `reason` and structured protocol failures from `details`. - **Disabled renditions.** Audio and video convenience decoders reject a catalog configuration with `enabled: false`. Select an enabled rendition from the latest catalog before opening a decoder. - **Track demand.** Obtain a dynamic track request’s `demand()` before accepting it to watch subscribers through the handoff to its producer. The watch does not keep the track alive and ends when the track closes or the request is rejected. diff --git a/doc/lib/swift/index.md b/doc/lib/swift/index.md index e55f0029f41..499fda3cf6b 100644 --- a/doc/lib/swift/index.md +++ b/doc/lib/swift/index.md @@ -97,6 +97,7 @@ shows which module holds each. - **Decoded frames hold decoder buffers.** Release each frame from a `Video.Consumer` promptly, or the decoder stalls. `resize` is best effort, so read each frame's `width()` and `height()`. - **Closing.** `try await session.shutdown()` drains for up to one second and throws on failure. `session.cancel(code:)` closes at once. Finish or abort live tracks first. Application delivery requires explicitly selecting `moq-lite-07-wip` on both peers; older Lite versions (including default lite-06) and IETF wait only for transport ACKs. - **Stats.** `session.stats()` reports `rttUs`, `estimatedSendRateBps`, and the byte and packet counters (`bytesSent`, `bytesReceived`, `bytesLost`, `packetsSent`, `packetsReceived`, `packetsLost`). A field is `nil` when the transport does not report it, which is not the same as zero. `version` names the negotiated protocol, and while a client session reconnects `session.failure()` says why. Hold the owned `Bandwidth.Consumer` returned by `session.recvBandwidth()` to receive bandwidth estimates. `peek()` reads the current estimate in bits per second and `changed()` waits for a new one. Release the consumer or call `cancel()` when finished; releasing the last consumer stops PROBE; the session and stats snapshots do not request it. Client consumers survive reconnects and report no estimate while disconnected. +- **Authorization.** On `moq-lite-07-wip` (opt in with `Client(versions:)`), `session.auth().grant()` is the `Grant` the relay sent back, `nil` until it answers and while a client session reconnects; `try await auth.grantChanged()` waits for the next one. `try await auth.add(token:)` presents a fresh token without reconnecting, returns an `AuthToken` whose grant joins the union until `cancel()`, and is presented again on every reconnect. A refusal throws with the relay's code. - **Errors.** Diagnostic cases use the `reason:` argument label; match their associated value to inspect it. Protocol errors carry `details`. - **Disabled renditions.** Audio and video convenience decoders reject a catalog configuration with `enabled: false`. Select an enabled rendition from the latest catalog before opening a decoder. - **Track demand.** Obtain a dynamic track request’s `demand()` before accepting it to watch subscribers through the handoff to its producer. The watch does not keep the track alive and ends when the track closes or the request is rejected. diff --git a/py/moq-rs/README.md b/py/moq-rs/README.md index 4db0313ebeb..28bb9f551df 100644 --- a/py/moq-rs/README.md +++ b/py/moq-rs/README.md @@ -158,6 +158,7 @@ client = moq.Client( - `.stats() → ConnectionStats`. Snapshot RTT, bandwidth estimates, and byte/packet counters. - `await .status() → ConnectionStatus`, `.connects()`, `.failure()`. Watch reconnects; `connects()` counts connections, 1 on the first, and `failure()` says why a reconnect is under way. - `.bandwidth() → Bandwidth`. Divide the send estimate between encoders and app-owned tracks. + - `.auth() → Auth`. On `moq-lite-07-wip`, `.grant()` reads the peer's `Grant` and `await .add(token) → AuthToken` presents a fresh token without reconnecting. ### Publishing diff --git a/py/moq-rs/docs/index.md b/py/moq-rs/docs/index.md index 94050e1c24c..64e1347d2dd 100644 --- a/py/moq-rs/docs/index.md +++ b/py/moq-rs/docs/index.md @@ -40,6 +40,9 @@ asyncio.run(main()) Session Request Transport + Auth + AuthToken + Grant ``` ## Publishing From 66252283f02c25988315facbb6be8f26c7e077e7 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 20 Oct 2026 18:42:12 -0700 Subject: [PATCH 19/45] feat(obs): present a token edited mid-stream without reconnecting Co-Authored-By: Claude Opus 5.5 --- cpp/obs/src/moq-dock.cpp | 36 ++++++++++++++++++++++++++++---- cpp/obs/src/moq-dock.h | 4 ++++ cpp/obs/src/moq-output.cpp | 26 +++++++++++++++++++++++ cpp/obs/src/moq-output.h | 11 ++++++++++ cpp/obs/test/moq-output-test.cpp | 18 ++++++++++++++++ doc/bin/obs.md | 5 ++++- 6 files changed, 95 insertions(+), 5 deletions(-) diff --git a/cpp/obs/src/moq-dock.cpp b/cpp/obs/src/moq-dock.cpp index 8dd021203f7..10f66b5774a 100644 --- a/cpp/obs/src/moq-dock.cpp +++ b/cpp/obs/src/moq-dock.cpp @@ -414,7 +414,9 @@ MoQDock::MoQDock(QWidget *parent) : QWidget(parent) tokenEdit->setPlaceholderText("(optional) publish JWT"); tokenEdit->setEchoMode(QLineEdit::Password); const QString tokenHelp = "Optional relay publish token. Leave empty for public relays such as /anon. " - "Pasting a URL with ?jwt= into Relay URL fills this and strips it from the URL."; + "Pasting a URL with ?jwt= into Relay URL fills this and strips it from the URL. " + "Editing it while live presents the new token without reconnecting, on a session " + "that carries in-band authorization."; tokenEdit->setToolTip(tokenHelp); pathEdit = new QLineEdit(streamPage); @@ -609,7 +611,7 @@ MoQDock::MoQDock(QWidget *parent) : QWidget(parent) connect(pollTimer, &QTimer::timeout, this, &MoQDock::UpdateStatus); connect(urlEdit, &QLineEdit::editingFinished, this, &MoQDock::OnRelayUrlEdited); - connect(tokenEdit, &QLineEdit::editingFinished, this, &MoQDock::SaveSettings); + connect(tokenEdit, &QLineEdit::editingFinished, this, &MoQDock::OnTokenEdited); connect(pathEdit, &QLineEdit::editingFinished, this, &MoQDock::SaveSettings); connect(encodingMode, QOverload::of(&QComboBox::currentIndexChanged), this, &MoQDock::OnEncodingChanged); connect(latencyMode, QOverload::of(&QComboBox::currentIndexChanged), this, &MoQDock::SaveSettings); @@ -675,6 +677,31 @@ void MoQDock::OnRelayUrlEdited() SaveSettings(); } +void MoQDock::OnTokenEdited() +{ + SaveSettings(); + + const QString token = tokenEdit->text().trimmed(); + if (!running || !output || token.isEmpty() || token == presentedToken) + return; + + // The same refusal as Go Live: the URL is fixed while running, but the token + // may be new. + const QUrl dial(urlEdit->text().trimmed()); + if (dial.isValid() && MoQDial::IsCleartext(dial.scheme().toStdString())) { + status->setText("Publish token requires an encrypted relay URL (not http://, ws://, or tcp://)"); + return; + } + + // Presented in band, so the stream keeps going while the relay checks it. + auto *moq = static_cast(obs_obj_get_data(output)); + if (!moq || !moq->PresentToken(token.toStdString())) { + status->setText("Not connected yet; edit the token again once live"); + return; + } + presentedToken = token; +} + void MoQDock::PeelJwtFromRelayUrl() { QString text = urlEdit->text().trimmed(); @@ -1069,6 +1096,7 @@ void MoQDock::StartStream() const std::string url = ConnectUrl().toStdString(); const std::string path = pathEdit->text().toStdString(); + presentedToken = token; SaveSettings(); @@ -1158,10 +1186,10 @@ void MoQDock::SetRunning(bool isRunning) .arg(isRunning ? "#e04434" : "#36a45e")); urlEdit->setEnabled(!isRunning); - tokenEdit->setEnabled(!isRunning); pathEdit->setEnabled(!isRunning); // The settings are read once at connect, so editing them mid-stream would look - // like it applied when it hadn't. + // like it applied when it hadn't. The token stays editable: a new one is + // presented on the live session. advancedButton->setEnabled(!isRunning); encodingMode->setEnabled(!isRunning); latencyMode->setEnabled(!isRunning); diff --git a/cpp/obs/src/moq-dock.h b/cpp/obs/src/moq-dock.h index 0d4e7d886f0..6e4a4610be2 100644 --- a/cpp/obs/src/moq-dock.h +++ b/cpp/obs/src/moq-dock.h @@ -37,6 +37,7 @@ private slots: void OnEncodingChanged(int mode); void RefreshEncodingOptions(bool applyProfileDefaults = false); void OnRelayUrlEdited(); + void OnTokenEdited(); private: void StartStream(); @@ -96,6 +97,9 @@ private slots: OBSEncoderAutoRelease audioEncoder; bool running = false; + // The token the running stream last presented, so leaving the field unchanged + // does not present it again. + QString presentedToken; // Filled when Go Live creates encoders; shown in Stats while publishing. QString publishSummary; QString latencySummary; diff --git a/cpp/obs/src/moq-output.cpp b/cpp/obs/src/moq-output.cpp index 577993d45de..0c76d599857 100644 --- a/cpp/obs/src/moq-output.cpp +++ b/cpp/obs/src/moq-output.cpp @@ -570,6 +570,32 @@ void MoQOutput::CopyLastFailure(int *code, std::string *reason) *reason = last_failure_reason; } +bool MoQOutput::PresentToken(const std::string &token) +{ + std::lock_guard signal_lock(signal_mutex); + if (!attempt || !attempt->session) + return false; + + // Replacing `presenting` cancels a presentation still in flight, which the + // newer token supersedes. + attempt->presenting = attempt->session->auth()->add(token).then( + worker.Executor(), Current>>( + attempt, [](const std::shared_ptr ¤t, auto result) { + if (!result) { + LOG_WARNING("MoQ relay refused the new publish token: %s", + result.error().to_string().c_str()); + return; + } + // The old token's grant leaves the union only after the new one joined. + if (current->token) + current->token->cancel(); + current->token = *result; + LOG_INFO("MoQ relay accepted the new publish token: %s", + MoQRedactUrl(current->url).c_str()); + })); + return true; +} + bool MoQOutput::TryGetVideoRate(VideoRate *out) { VideoRate rate; diff --git a/cpp/obs/src/moq-output.h b/cpp/obs/src/moq-output.h index 1b903f4b400..ed57e8c755d 100644 --- a/cpp/obs/src/moq-output.h +++ b/cpp/obs/src/moq-output.h @@ -76,6 +76,12 @@ class MoQOutput { }; bool TryGetVideoRate(VideoRate *out); + // Present a fresh publish token on the connected session without reconnecting, + // replacing the one presented here before once the relay accepts it. False when + // there is no session yet. The outcome is logged; a relay without in-band AUTH + // refuses it. + bool PresentToken(const std::string &token); + private: friend struct MoQOutputStatsTest; bool CommitConnectionStats(const std::shared_ptr ¤t, ConnectionStats snapshot, @@ -97,6 +103,11 @@ class MoQOutput { bool connected = false; // False while the session is between reconnects. bool live = false; + // The latest token PresentToken() got accepted, presented on every reconnect + // until it is replaced or the attempt is dropped, and the call presenting the + // next one. + std::shared_ptr token; + std::optional presenting; }; void OnConnect(const std::shared_ptr &attempt, moq::expected> result); diff --git a/cpp/obs/test/moq-output-test.cpp b/cpp/obs/test/moq-output-test.cpp index 81183206ef1..ad2f8ca3afc 100644 --- a/cpp/obs/test/moq-output-test.cpp +++ b/cpp/obs/test/moq-output-test.cpp @@ -813,6 +813,24 @@ int main() } printf("OBS-driven restart: ok\n"); + // A token edited mid-stream goes to the live session only, and presenting a + // newer one or stopping cancels whatever is still in flight. + { + TestRelay relay; + reset(relay.Url()); + MoQOutput o(nullptr, OUTPUT); + CHECK(!o.PresentToken("early")); + CHECK(o.Start()); + CHECK(WaitFor([&] { return o.IsLiveSession(); })); + CHECK(o.PresentToken("fresh")); + CHECK(o.PresentToken("fresher")); + CHECK(o.IsLiveSession()); + CHECK(signalCount() == 0); + o.Stop(); + CHECK(!o.PresentToken("late")); + } + printf("present a token mid-stream: ok\n"); + // Restarts and teardown racing failures still in flight, repeatedly. Whatever // the order, each Start() reports at most once, and nothing reports once the // output is gone. diff --git a/doc/bin/obs.md b/doc/bin/obs.md index ea9d565d1cb..3becb3d2621 100644 --- a/doc/bin/obs.md +++ b/doc/bin/obs.md @@ -23,7 +23,10 @@ The source plays Opus, AAC-LC (mono or stereo), and PCM audio. A channel count n Enter a relay URL explicitly. On the shared anonymous relay, use a unique path such as `https://cdn.moq.dev/anon/your-stream`. A URL with `?jwt=` fills the -token field. +token field. Editing the token while live presents the new one without +reconnecting, on a session that carries in-band authorization. Only +`moq-lite-07-wip` does, and it is not offered yet, so for now the OBS log +reports the new token as unsupported and it applies on the next Go Live. By default the dock tunes the encoder for low latency on this stream only: no B-frames or lookahead on the encoders that support turning them off. This From cb26bb3ad5f0529a17606d935d56e11fc2dd2c6e Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 20 Oct 2026 18:43:13 -0700 Subject: [PATCH 20/45] quest: finish auth bindings Co-Authored-By: Claude Opus 5.5 --- quest/m1/auth/README.md | 2 -- quest/m1/auth/bindings.md | 45 ------------------------------ quest/m1/auth/issuer.md | 2 +- quest/m1/auth/token-in-band.md | 5 ---- quest/m3/request-token-surfaces.md | 2 +- 5 files changed, 2 insertions(+), 54 deletions(-) delete mode 100644 quest/m1/auth/bindings.md diff --git a/quest/m1/auth/README.md b/quest/m1/auth/README.md index 17f6a6024d9..43ad2a025ab 100644 --- a/quest/m1/auth/README.md +++ b/quest/m1/auth/README.md @@ -145,8 +145,6 @@ because `main` assigned 0x3A to NOT_FETCHABLE first. ## Required - [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 -- [Bindings](/quest/m1/auth/bindings.md) - grants and tokens reach every - binding through moq-ffi - [Token in band](/quest/m1/auth/token-in-band.md) - extra credentials ride AUTH streams: a session starts on the token its SETUP or URL carried and its AUTH streams add the rest diff --git a/quest/m1/auth/bindings.md b/quest/m1/auth/bindings.md deleted file mode 100644 index c19d3bb7d88..00000000000 --- a/quest/m1/auth/bindings.md +++ /dev/null @@ -1,45 +0,0 @@ -# [M] Bindings - -## Goal - -Every binding can read a session's grant and present more tokens: moq-ffi -exposes it, the generated C and C++ bindings pick it up from moq-ffi, and the -hand-written Python, Swift, Kotlin, Go, and Dart wrappers and their docs carry -it. An OBS user whose token is about to expire mid-stream can be handed a new -one without the plugin reconnecting. - -## Plan - -This quest exposes session AUTH only. Per-request setters and verifier APIs -remain deferred until a consumer needs them (confirmed 2026-10-12); do not -export the experimental Rust request hook through the bindings here. - -- `MoqSession` in `rs/moq-ffi/src/session.rs` gains `auth()` returning an - `Arc` object, the shape `publisher()` uses, with `grant() -> - Option` (a record: publish patterns, subscribe patterns, expiry - as a duration, matching `auth::Grant`) for the union, `async grant_changed() -> MoqGrant` so a caller - can await the next change without polling, and `async add(token: String) -> - Arc` whose object carries that token's own `grant()` and - `closed()` and whose release withdraws it, raising the structured error - moq-ffi already maps on refusal. `MoqClient` sessions reached through the - reconnecting connection use `moq_tokio::Connection::auth()`. -- No new libmoq API: the hand-written C ABI gets no more feature work. The - generated C and C++ bindings get this from moq-ffi, and OBS reaches it - through the generated C++; update `cpp/obs/src` - only if the plugin surfaces a token field. -- Interop: the Python, Go, and C++ interop clients print their grant as the - `auth granted publish=[...] subscribe=[...]` line and join `prints_grant` and - `enforces_grant` in `test/interop/interop.sh`, beside Rust and JS. -- Wrappers: `py/moq-rs/moq/session.py`, `swift/Sources/Moq`, - `kt/.../Flows.kt` (a `Flow` over `grant_changed`), `go/wrapper/session.go` - (context-cancellable like the rest), and `dart/moq/lib/moq.dart`. Kotlin - typealiases pick up the raw methods for free; the flow is the idiomatic add. -- Docs: `doc/lib/{py,swift,kt,go,dart}` each gain a short auth section, and - `doc/lib/rs` documents `Session::auth()`. -- Tests: each wrapper's existing session test reads a grant from a local - relay, adds a second token minted by `moq auth`, sees the union grow, and - releases it; the refusal path surfaces the structured error in each - language. - -Additive. Decided in the 2026-09-30 audit: libmoq is frozen, so the C -surface comes from moq-ffi rather than new `moq_session_auth_*` calls. diff --git a/quest/m1/auth/issuer.md b/quest/m1/auth/issuer.md index 1f4a1af0e06..6acc18cf6f3 100644 --- a/quest/m1/auth/issuer.md +++ b/quest/m1/auth/issuer.md @@ -47,4 +47,4 @@ 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 +- `rs/moq-ffi/src/auth.rs` - wraps both the session and the connection handle behind one `MoqAuth`; collapse it onto this shape diff --git a/quest/m1/auth/token-in-band.md b/quest/m1/auth/token-in-band.md index 3c189cd81d9..e5413ae7086 100644 --- a/quest/m1/auth/token-in-band.md +++ b/quest/m1/auth/token-in-band.md @@ -87,8 +87,3 @@ already carries the full pattern grant. cross-language harness runs with tokens configured. Additive. Wire: a lite-07-wip SETUP parameter. - -## Required - -- [Bindings](/quest/m1/auth/bindings.md) - supplies the client surface the new - token setters sit beside diff --git a/quest/m3/request-token-surfaces.md b/quest/m3/request-token-surfaces.md index a5cc15889ec..2d6f60dace7 100644 --- a/quest/m3/request-token-surfaces.md +++ b/quest/m3/request-token-surfaces.md @@ -18,7 +18,7 @@ or acceptor to mirror. So each surface here exposes that unified shape once Rust has it, and only for the surface a named consumer uses. Start by asking that consumer which surface it needs. Do not build all three. Through moq-ffi, follow the session -AUTH shape that [Bindings](/quest/m1/auth/bindings.md) settles. Update every +AUTH shape of `MoqAuth` in `rs/moq-ffi/src/auth.rs`. Update every wrapper, as the cross-package table requires. Public API: whatever the chosen surface needs to present and accept auths From 5b2049663f05e685417382cd6b380fd0450d3d0c Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 20 Oct 2026 18:56:25 -0700 Subject: [PATCH 21/45] test(dart,moq-ffi): drain the client before the server, and format Co-Authored-By: Claude Opus 5.5 --- dart/moq/test/moq_test.dart | 3 ++- rs/moq-ffi/src/test.rs | 11 +++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/dart/moq/test/moq_test.dart b/dart/moq/test/moq_test.dart index dd942ffdab0..05b30c8b16c 100644 --- a/dart/moq/test/moq_test.dart +++ b/dart/moq/test/moq_test.dart @@ -196,8 +196,9 @@ void main() { ); expectEverything(client.auth().grant()); - serverSession.cancel(code: 0); + // The client drains first: cancelling the server session would fail its shutdown. await client.close(); + serverSession.cancel(code: 0); server.cancel(); }); diff --git a/rs/moq-ffi/src/test.rs b/rs/moq-ffi/src/test.rs index 5f7a7a93ac9..ceda7584038 100644 --- a/rs/moq-ffi/src/test.rs +++ b/rs/moq-ffi/src/test.rs @@ -6805,7 +6805,11 @@ async fn auth_reads_the_peer_grant() { .err() .expect("the default acceptor refuses a token"); assert!(matches!(err, MoqError::Unsupported), "refusal: {err:?}"); - assert_eq!(client.auth().grant(), Some(grant(&["**"], &["**"])), "a refusal leaves the union"); + assert_eq!( + client.auth().grant(), + Some(grant(&["**"], &["**"])), + "a refusal leaves the union" + ); client.cancel(0); accepted.cancel(0); @@ -6895,5 +6899,8 @@ async fn auth_tokens_join_and_leave_the_union() { assert_eq!(auth.grant(), Some(grant(&["setup/**"], &[]))); session.cancel(0); - tokio::time::timeout(TIMEOUT, serving).await.expect("server ended").unwrap(); + tokio::time::timeout(TIMEOUT, serving) + .await + .expect("server ended") + .unwrap(); } From d016fa689dc2cbd0993b4f2c92a711e93cbf248a Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 20 Oct 2026 20:28:00 -0700 Subject: [PATCH 22/45] fix(bindings): independent grant watchers, and refuse tokens nothing sends - `MoqSession::auth()` returns a new handle per call, each watching the grant on its own and starting from the grant it was made with, so concurrent watchers no longer steal each other's changes and `grants()` never repeats the first grant. - `MoqAuth::add` refuses a token with `Unsupported` when the session cannot send it: moq-transport without the Auth extension accepts one only for the requests that name it, which no binding can. - moq-tokio publishes a token's grant before `add` returns. - OBS keeps the relay's refusal for the dock and presents a retyped token again. - Docs: AUTH also rides moq-transport draft-17+ with the Auth extension. - interop.sh guards empty arrays for bash 3.2; the Swift grant wait has a watchdog. Co-Authored-By: Claude Opus 5.5 --- cpp/obs/src/moq-dock.cpp | 19 ++++--- cpp/obs/src/moq-dock.h | 6 +-- cpp/obs/src/moq-output.cpp | 18 ++++++- cpp/obs/src/moq-output.h | 7 ++- cpp/obs/test/moq-output-test.cpp | 3 ++ dart/moq/lib/src/client.dart | 3 +- doc/bin/obs.md | 8 +-- doc/lib/cpp/index.md | 2 +- doc/lib/dart/index.md | 2 +- doc/lib/go/index.md | 2 +- doc/lib/index.md | 2 +- doc/lib/kt/index.md | 2 +- doc/lib/py/index.md | 2 +- doc/lib/swift/index.md | 2 +- go/wrapper/session.go | 8 +-- .../jvmAndAndroidMain/kotlin/dev/moq/Flows.kt | 6 +-- py/moq-rs/README.md | 2 +- py/moq-rs/moq/session.py | 8 +-- rs/moq-ffi/src/auth.rs | 39 ++++++++++---- rs/moq-ffi/src/session.rs | 19 ++++--- rs/moq-ffi/src/test.rs | 54 ++++++++++++++++--- rs/moq-tokio/src/connection/auth.rs | 7 +-- swift/Sources/Moq/Auth.swift | 3 +- swift/Sources/Moq/Client.swift | 5 +- swift/Tests/MoqTests/SmokeTests.swift | 9 ++++ test/interop/interop.sh | 12 ++--- 26 files changed, 178 insertions(+), 72 deletions(-) diff --git a/cpp/obs/src/moq-dock.cpp b/cpp/obs/src/moq-dock.cpp index 10f66b5774a..4d01c56fdb1 100644 --- a/cpp/obs/src/moq-dock.cpp +++ b/cpp/obs/src/moq-dock.cpp @@ -611,6 +611,7 @@ MoQDock::MoQDock(QWidget *parent) : QWidget(parent) connect(pollTimer, &QTimer::timeout, this, &MoQDock::UpdateStatus); connect(urlEdit, &QLineEdit::editingFinished, this, &MoQDock::OnRelayUrlEdited); + connect(tokenEdit, &QLineEdit::textEdited, this, [this] { tokenEdited = true; }); connect(tokenEdit, &QLineEdit::editingFinished, this, &MoQDock::OnTokenEdited); connect(pathEdit, &QLineEdit::editingFinished, this, &MoQDock::SaveSettings); connect(encodingMode, QOverload::of(&QComboBox::currentIndexChanged), this, &MoQDock::OnEncodingChanged); @@ -682,7 +683,7 @@ void MoQDock::OnTokenEdited() SaveSettings(); const QString token = tokenEdit->text().trimmed(); - if (!running || !output || token.isEmpty() || token == presentedToken) + if (!running || !output || token.isEmpty() || !tokenEdited) return; // The same refusal as Go Live: the URL is fixed while running, but the token @@ -699,7 +700,7 @@ void MoQDock::OnTokenEdited() status->setText("Not connected yet; edit the token again once live"); return; } - presentedToken = token; + tokenEdited = false; } void MoQDock::PeelJwtFromRelayUrl() @@ -1096,7 +1097,7 @@ void MoQDock::StartStream() const std::string url = ConnectUrl().toStdString(); const std::string path = pathEdit->text().toStdString(); - presentedToken = token; + tokenEdited = false; SaveSettings(); @@ -1287,14 +1288,20 @@ void MoQDock::UpdateStatus() return; } - status->setText(stats.reconnects > 0 ? QString("● Connected · reconnects %1").arg(stats.reconnects) - : QString("● Connected")); - status->setStyleSheet("color: #36a45e;"); + const QString tokenRefusal = QString::fromStdString(moq->TokenRefusal()); + QString connectedText = stats.reconnects > 0 ? QString("● Connected · reconnects %1").arg(stats.reconnects) + : QString("● Connected"); + if (!tokenRefusal.isEmpty()) + connectedText += QStringLiteral(" · new token refused"); + status->setText(connectedText); + status->setStyleSheet(tokenRefusal.isEmpty() ? "color: #36a45e;" : "color: #d08b1d;"); { QStringList lines; if (!publishSummary.isEmpty()) lines << publishSummary; + if (!tokenRefusal.isEmpty()) + lines << QString("New publish token refused: %1").arg(tokenRefusal); if (!stats.protocol.empty()) lines << QString("Protocol %1").arg(QString::fromStdString(stats.protocol)); if (!stats.dial.empty()) diff --git a/cpp/obs/src/moq-dock.h b/cpp/obs/src/moq-dock.h index 6e4a4610be2..393490c5df4 100644 --- a/cpp/obs/src/moq-dock.h +++ b/cpp/obs/src/moq-dock.h @@ -97,9 +97,9 @@ private slots: OBSEncoderAutoRelease audioEncoder; bool running = false; - // The token the running stream last presented, so leaving the field unchanged - // does not present it again. - QString presentedToken; + // Whether the token field was typed in since its token was last presented, so + // leaving it unchanged does not present it again while a retyped one does. + bool tokenEdited = false; // Filled when Go Live creates encoders; shown in Stats while publishing. QString publishSummary; QString latencySummary; diff --git a/cpp/obs/src/moq-output.cpp b/cpp/obs/src/moq-output.cpp index 0c76d599857..cae48c6abcd 100644 --- a/cpp/obs/src/moq-output.cpp +++ b/cpp/obs/src/moq-output.cpp @@ -405,6 +405,7 @@ void MoQOutput::Reset() epoch.clear(); last_failure_code = MoQError::None; last_failure_reason.clear(); + token_refusal.clear(); } connect_time_ms = 0; @@ -570,20 +571,33 @@ void MoQOutput::CopyLastFailure(int *code, std::string *reason) *reason = last_failure_reason; } +std::string MoQOutput::TokenRefusal() +{ + std::lock_guard lock(mutex); + return token_refusal; +} + bool MoQOutput::PresentToken(const std::string &token) { std::lock_guard signal_lock(signal_mutex); if (!attempt || !attempt->session) return false; + { + std::lock_guard lock(mutex); + token_refusal.clear(); + } // Replacing `presenting` cancels a presentation still in flight, which the // newer token supersedes. attempt->presenting = attempt->session->auth()->add(token).then( worker.Executor(), Current>>( - attempt, [](const std::shared_ptr ¤t, auto result) { + attempt, [this](const std::shared_ptr ¤t, auto result) { if (!result) { + const std::string reason = result.error().to_string(); LOG_WARNING("MoQ relay refused the new publish token: %s", - result.error().to_string().c_str()); + reason.c_str()); + std::lock_guard lock(mutex); + token_refusal = reason; return; } // The old token's grant leaves the union only after the new one joined. diff --git a/cpp/obs/src/moq-output.h b/cpp/obs/src/moq-output.h index ed57e8c755d..b70b00c19eb 100644 --- a/cpp/obs/src/moq-output.h +++ b/cpp/obs/src/moq-output.h @@ -78,9 +78,11 @@ class MoQOutput { // Present a fresh publish token on the connected session without reconnecting, // replacing the one presented here before once the relay accepts it. False when - // there is no session yet. The outcome is logged; a relay without in-band AUTH - // refuses it. + // there is no session yet. The outcome is logged, and a refusal is kept for + // TokenRefusal(); a session without in-band AUTH refuses it. bool PresentToken(const std::string &token); + // Why the relay refused the token PresentToken() last presented, or empty. + std::string TokenRefusal(); private: friend struct MoQOutputStatsTest; @@ -179,6 +181,7 @@ class MoQOutput { std::string epoch; int last_failure_code = 0; std::string last_failure_reason; + std::string token_refusal; // Written on the worker, read by GetConnectTime(). std::atomic connect_time_ms{0}; diff --git a/cpp/obs/test/moq-output-test.cpp b/cpp/obs/test/moq-output-test.cpp index ad2f8ca3afc..45b4ab35d93 100644 --- a/cpp/obs/test/moq-output-test.cpp +++ b/cpp/obs/test/moq-output-test.cpp @@ -824,6 +824,9 @@ int main() CHECK(WaitFor([&] { return o.IsLiveSession(); })); CHECK(o.PresentToken("fresh")); CHECK(o.PresentToken("fresher")); + // The default protocol carries no in-band AUTH, so the relay's refusal is + // kept for the dock while the stream stays up. + CHECK(WaitFor([&] { return !o.TokenRefusal().empty(); })); CHECK(o.IsLiveSession()); CHECK(signalCount() == 0); o.Stop(); diff --git a/dart/moq/lib/src/client.dart b/dart/moq/lib/src/client.dart index 55fcbc2c1de..c8d069dac90 100644 --- a/dart/moq/lib/src/client.dart +++ b/dart/moq/lib/src/client.dart @@ -279,7 +279,8 @@ final class Moq { extension AuthGrants on Auth { /// The current grant, then each change, null while there is none. /// - /// Ends when the session shuts down, and errors with its close reason otherwise. + /// Listens on one [Auth] share its place in the changes, so a second watcher + /// takes its own from [Moq.auth]. Ends when the session shuts down, and errors with its close reason otherwise. Stream grants() async* { yield grant(); while (true) { diff --git a/doc/bin/obs.md b/doc/bin/obs.md index 3becb3d2621..83b7c8b5251 100644 --- a/doc/bin/obs.md +++ b/doc/bin/obs.md @@ -24,9 +24,11 @@ The source plays Opus, AAC-LC (mono or stereo), and PCM audio. A channel count n Enter a relay URL explicitly. On the shared anonymous relay, use a unique path such as `https://cdn.moq.dev/anon/your-stream`. A URL with `?jwt=` fills the token field. Editing the token while live presents the new one without -reconnecting, on a session that carries in-band authorization. Only -`moq-lite-07-wip` does, and it is not offered yet, so for now the OBS log -reports the new token as unsupported and it applies on the next Go Live. +reconnecting, on a session that carries in-band authorization: a +`moq-transport` draft-17 or later version pinned under Advanced settings, with +a relay that negotiates the MoQ Auth extension. The default protocol does not +yet, so there the dock reports the new token as refused and it applies on the +next Go Live. By default the dock tunes the encoder for low latency on this stream only: no B-frames or lookahead on the encoders that support turning them off. This diff --git a/doc/lib/cpp/index.md b/doc/lib/cpp/index.md index a5888794490..8d8367bb012 100644 --- a/doc/lib/cpp/index.md +++ b/doc/lib/cpp/index.md @@ -198,7 +198,7 @@ Obtain a dynamic track request’s `demand()` before accepting it to watch subsc ## Authorization -On `moq-lite-07-wip` (opt in with `ClientConfig::versions`), `session->auth()->grant()` is the `Grant` the relay sent back, empty until it answers and while a client session reconnects; `grant_changed()` waits for the next one. `add(token)` presents a fresh token without reconnecting and resolves to an `AuthToken` whose grant joins the union until `cancel()`; it is presented again on every reconnect. A refusal resolves to an `Error` carrying the relay's code. +On `moq-lite-07-wip` (opt in with `ClientConfig::versions`) and on moq-transport draft-17+ with the [MoQ Auth extension](/draft/moq-auth), `session->auth()->grant()` is the `Grant` the relay sent back, empty until it answers and while a client session reconnects; `grant_changed()` waits for the next one. `add(token)` presents a fresh token without reconnecting and resolves to an `AuthToken` whose grant joins the union until `cancel()`; it is presented again on every reconnect. A refusal resolves to an `Error` carrying the relay's code. ## Stats diff --git a/doc/lib/dart/index.md b/doc/lib/dart/index.md index 814ac2b09b4..70add97a2a2 100644 --- a/doc/lib/dart/index.md +++ b/doc/lib/dart/index.md @@ -136,7 +136,7 @@ shows which module holds each. - **Decoded frames hold decoder buffers.** `dispose()` each frame from a `video.Consumer` promptly rather than waiting for the garbage collector, or the decoder stalls. `resize` is best effort, so read each frame's `width()` and `height()`. - **Closing.** `await moq.close()` or `session.shutdown()` drains for up to one second and reports any error. `session.cancel(code: 0)` closes at once. Finish or abort live tracks first. Cancelling a stream subscription releases its native cursor. Application delivery requires explicitly selecting `moq-lite-07-wip` on both peers; older Lite versions (including default lite-06) and IETF wait only for transport ACKs. - **Stats.** `session.stats()` reports `rttUs`, `estimatedSendRateBps`, and the byte and packet counters (`bytesSent`, `bytesReceived`, `bytesLost`, `packetsSent`, `packetsReceived`, `packetsLost`). A field is `null` when the transport does not report it, which is not the same as zero. `version` names the negotiated protocol, and while a client session reconnects `session.failure()` says why. Hold the owned `BandwidthConsumer` returned by `session.recvBandwidth()` to receive bandwidth estimates. `peek()` reads the current estimate in bits per second and `changed()` waits for a new one. Release the consumer or call `cancel()` when finished; releasing the last consumer stops PROBE; the session and stats snapshots do not request it. Client consumers survive reconnects and report no estimate while disconnected. -- **Authorization.** On `moq-lite-07-wip` (opt in with `ConnectOptions(versions: ...)`), listen to `moq.auth().grants()` for the `Grant` the relay sent back, `null` until it answers and while a client session reconnects. `await auth().add(token: ...)` presents a fresh token without reconnecting, returns an `AuthToken` whose grant joins the union until `cancel()`, and is presented again on every reconnect. A refusal throws with the relay's code. +- **Authorization.** On `moq-lite-07-wip` (opt in with `ConnectOptions(versions: ...)`) and on moq-transport draft-17+ with the [MoQ Auth extension](/draft/moq-auth), listen to `moq.auth().grants()` for the `Grant` the relay sent back, `null` until it answers and while a client session reconnects. `await auth().add(token: ...)` presents a fresh token without reconnecting, returns an `AuthToken` whose grant joins the union until `cancel()`, and is presented again on every reconnect. A refusal throws with the relay's code. - **Errors.** Read diagnostic payloads from `reason` and structured protocol failures from `details`. - **Disabled renditions.** Audio and video convenience decoders reject a catalog configuration with `enabled: false`. Select an enabled rendition from the latest catalog before opening a decoder. - **Track demand.** Obtain a dynamic track request’s `demand()` before accepting it to watch subscribers through the handoff to its producer. The watch does not keep the track alive and ends when the track closes or the request is rejected. diff --git a/doc/lib/go/index.md b/doc/lib/go/index.md index 7f65e14765e..c517333ae5e 100644 --- a/doc/lib/go/index.md +++ b/doc/lib/go/index.md @@ -126,7 +126,7 @@ shows which module holds each. - **Decoded frames hold decoder buffers.** `Close` each `VideoDecodedFrame` promptly, or the decoder stalls. `Resize` is best effort, so read each frame's `Width()` and `Height()`. - **Closing.** `client.Close()` drains for up to one second and returns an error on failure. `session.Shutdown(ctx)` does the same, and `session.Cancel(code)` closes at once. Finish or abort live tracks first. Application delivery requires explicitly selecting `moq-lite-07-wip` on both peers; older Lite versions (including default lite-06) and IETF wait only for transport ACKs. - **Stats.** `Session().Stats()` reports `RTT`, `EstimatedSendRateBps`, and the byte and packet counters (`BytesSent`, `BytesReceived`, `BytesLost`, `PacketsSent`, `PacketsReceived`, `PacketsLost`). A field is nil when the transport does not report it, which is not the same as zero. `Version` names the negotiated protocol, and while a client session reconnects `Session().Failure()` says why. Hold the owned `BandwidthConsumer` returned by `Session().RecvBandwidth()` to receive bandwidth estimates. `Peek()` reads the current estimate in bits per second and `Changed(ctx)` waits for a new one. Calling `Close()` on the last consumer stops PROBE; the session and stats snapshots do not request it. Client consumers survive reconnects and report no estimate while disconnected. -- **Authorization.** On `moq-lite-07-wip` (opt in with `moq.WithVersions`), `Session().Auth().Grant()` is the `Grant` the relay sent back, nil until it answers and while a client session reconnects; `GrantChanged(ctx)` waits for the next one. `Add(ctx, token)` presents a fresh token without reconnecting, returns an `AuthToken` whose grant joins the union until `Close()`, and is presented again on every reconnect. A refusal returns the relay's code. +- **Authorization.** On `moq-lite-07-wip` (opt in with `moq.WithVersions`) and on moq-transport draft-17+ with the [MoQ Auth extension](/draft/moq-auth), `Session().Auth().Grant()` is the `Grant` the relay sent back, nil until it answers and while a client session reconnects; `GrantChanged(ctx)` waits for the next one. `Add(ctx, token)` presents a fresh token without reconnecting, returns an `AuthToken` whose grant joins the union until `Close()`, and is presented again on every reconnect. A refusal returns the relay's code. - **Errors.** Read diagnostic payloads from `Reason` and structured protocol failures from `Details`. - **Disabled renditions.** Audio and video convenience decoders reject a catalog configuration with `enabled: false`. Select an enabled rendition from the latest catalog before opening a decoder. - **Track demand.** Obtain a dynamic track request’s `Demand()` before accepting it to watch subscribers through the handoff to its producer. The watch does not keep the track alive and ends when the track closes or the request is rejected. diff --git a/doc/lib/index.md b/doc/lib/index.md index b9e2df24484..fc8a5bc800b 100644 --- a/doc/lib/index.md +++ b/doc/lib/index.md @@ -45,7 +45,7 @@ how it looks in that language: - **Catalog extensions**: write your own section next to `video` and `audio`, and read others' back. - **Routes**: see which relays a broadcast came through, and advertise a cost as a standby publisher. - **Connection stats** and **errors** that tell an auth rejection (don't retry) from a shutdown or a transport failure. -- **Authorization in band**: read the grant the relay sent back (the patterns this side may publish and subscribe to, and when it lapses), and present a fresh token before the current one expires, without reconnecting. It needs `moq-lite-07-wip`, which is opt-in. +- **Authorization in band**: read the grant the relay sent back (the patterns this side may publish and subscribe to, and when it lapses), and present a fresh token before the current one expires, without reconnecting. It needs `moq-lite-07-wip`, which is opt-in, or moq-transport draft-17+ with the [MoQ Auth extension](/draft/moq-auth). C's audio decoder neither takes nor reports a playout delay, and C has no in-band authorization. diff --git a/doc/lib/kt/index.md b/doc/lib/kt/index.md index bb41bfc60be..dfcd6b68d4d 100644 --- a/doc/lib/kt/index.md +++ b/doc/lib/kt/index.md @@ -98,7 +98,7 @@ shows which module holds each. - **Decoded frames hold decoder buffers.** `close()` each frame from a `dev.moq.video.Consumer` promptly (or `use { }` it), or the decoder stalls. `resize` is best effort, so read each frame's `width()` and `height()`. - **Closing.** Suspending `moq.shutdown()` drains for up to one second and throws on failure. `Moq.close()`, and so `use { }`, cancels at once. Finish or abort live tracks first. Application delivery requires explicitly selecting `moq-lite-07-wip` on both peers; older Lite versions (including default lite-06) and IETF wait only for transport ACKs. - **Stats.** `session.stats()` reports `rttUs`, `estimatedSendRateBps`, and the byte and packet counters (`bytesSent`, `bytesReceived`, `bytesLost`, `packetsSent`, `packetsReceived`, `packetsLost`). A field is `null` when the transport does not report it, which is not the same as zero. `version` names the negotiated protocol, and while a client session reconnects `session.failure()` says why. Hold the owned `BandwidthConsumer` returned by `session.recvBandwidth()` to receive bandwidth estimates. `peek()` reads the current estimate in bits per second and `changed()` waits for a new one. Release the consumer or call `cancel()` when finished; releasing the last consumer stops PROBE; the session and stats snapshots do not request it. Client consumers survive reconnects and report no estimate while disconnected. -- **Authorization.** On `moq-lite-07-wip` (opt in with `ClientConfig(versions = ...)`), collect `moq.auth().grants()` for the `Grant` the relay sent back, `null` until it answers and while a client session reconnects. Suspending `auth().add(token)` presents a fresh token without reconnecting, returns an `AuthToken` whose grant joins the union until `cancel()`, and is presented again on every reconnect. A refusal throws with the relay's code. +- **Authorization.** On `moq-lite-07-wip` (opt in with `ClientConfig(versions = ...)`) and on moq-transport draft-17+ with the [MoQ Auth extension](/draft/moq-auth), collect `moq.auth().grants()` for the `Grant` the relay sent back, `null` until it answers and while a client session reconnects. Suspending `auth().add(token)` presents a fresh token without reconnecting, returns an `AuthToken` whose grant joins the union until `cancel()`, and is presented again on every reconnect. A refusal throws with the relay's code. - **Errors.** Read diagnostic payloads from `reason` and structured protocol failures from `details`. - **Disabled renditions.** Audio and video convenience decoders reject a catalog configuration with `enabled: false`. Select an enabled rendition from the latest catalog before opening a decoder. - **Track demand.** Obtain a dynamic track request’s `demand()` before accepting it to watch subscribers through the handoff to its producer. The watch does not keep the track alive and ends when the track closes or the request is rejected. diff --git a/doc/lib/py/index.md b/doc/lib/py/index.md index a768bf1c0fa..e985c80567d 100644 --- a/doc/lib/py/index.md +++ b/doc/lib/py/index.md @@ -113,7 +113,7 @@ shows which module holds each. - **Decoded frames hold decoder buffers.** Drop each frame from a `moq.video.Consumer` promptly, or the decoder stalls. `resize` is best effort, so read each frame's `width()` and `height()`. - **Closing.** `await session.shutdown()` drains for up to one second and raises on failure. `cancel(code)` closes at once. Finish or abort live tracks first. Application delivery requires explicitly selecting `moq-lite-07-wip` on both peers; older Lite versions (including default lite-06) and IETF wait only for transport ACKs. - **Stats.** `session.stats()` reports `rtt`, `estimated_send_rate_bps`, and the byte and packet counters (`bytes_sent`, `bytes_received`, `bytes_lost`, `packets_sent`, `packets_received`, `packets_lost`). A field is `None` when the transport does not report it, which is not the same as zero. `version` names the negotiated protocol, and while a client session reconnects `session.failure()` says why. Hold the owned `Bandwidth.Consumer` returned by `session.recv_bandwidth()` to receive bandwidth estimates. `peek()` reads the current estimate in bits per second and `changed()` waits for a new one. Release the consumer or call `cancel()` when finished; releasing the last consumer stops PROBE; the session and stats snapshots do not request it. Client consumers survive reconnects and report no estimate while disconnected. -- **Authorization.** On `moq-lite-07-wip` (opt in with `versions=`), `session.auth().grant()` is the `Grant` the relay sent back, `None` until it answers and while a client session reconnects; `await auth.grant_changed()` waits for the next one. `await auth.add(token)` presents a fresh token without reconnecting, returns an `AuthToken` whose grant joins the union until `cancel()`, and is presented again on every reconnect. A refusal raises with the relay's code. +- **Authorization.** On `moq-lite-07-wip` (opt in with `versions=`) and on moq-transport draft-17+ with the [MoQ Auth extension](/draft/moq-auth), `session.auth().grant()` is the `Grant` the relay sent back, `None` until it answers and while a client session reconnects; `await auth.grant_changed()` waits for the next one. `await auth.add(token)` presents a fresh token without reconnecting, returns an `AuthToken` whose grant joins the union until `cancel()`, and is presented again on every reconnect. A refusal raises with the relay's code. - **Errors.** Read diagnostic payloads from `reason` and structured protocol failures from `details`. - **Disabled renditions.** Audio and video convenience decoders reject a catalog configuration with `enabled: false`. Select an enabled rendition from the latest catalog before opening a decoder. - **Track demand.** Obtain a dynamic track request’s `demand()` before accepting it to watch subscribers through the handoff to its producer. The watch does not keep the track alive and ends when the track closes or the request is rejected. diff --git a/doc/lib/swift/index.md b/doc/lib/swift/index.md index 499fda3cf6b..8196a8b9202 100644 --- a/doc/lib/swift/index.md +++ b/doc/lib/swift/index.md @@ -97,7 +97,7 @@ shows which module holds each. - **Decoded frames hold decoder buffers.** Release each frame from a `Video.Consumer` promptly, or the decoder stalls. `resize` is best effort, so read each frame's `width()` and `height()`. - **Closing.** `try await session.shutdown()` drains for up to one second and throws on failure. `session.cancel(code:)` closes at once. Finish or abort live tracks first. Application delivery requires explicitly selecting `moq-lite-07-wip` on both peers; older Lite versions (including default lite-06) and IETF wait only for transport ACKs. - **Stats.** `session.stats()` reports `rttUs`, `estimatedSendRateBps`, and the byte and packet counters (`bytesSent`, `bytesReceived`, `bytesLost`, `packetsSent`, `packetsReceived`, `packetsLost`). A field is `nil` when the transport does not report it, which is not the same as zero. `version` names the negotiated protocol, and while a client session reconnects `session.failure()` says why. Hold the owned `Bandwidth.Consumer` returned by `session.recvBandwidth()` to receive bandwidth estimates. `peek()` reads the current estimate in bits per second and `changed()` waits for a new one. Release the consumer or call `cancel()` when finished; releasing the last consumer stops PROBE; the session and stats snapshots do not request it. Client consumers survive reconnects and report no estimate while disconnected. -- **Authorization.** On `moq-lite-07-wip` (opt in with `Client(versions:)`), `session.auth().grant()` is the `Grant` the relay sent back, `nil` until it answers and while a client session reconnects; `try await auth.grantChanged()` waits for the next one. `try await auth.add(token:)` presents a fresh token without reconnecting, returns an `AuthToken` whose grant joins the union until `cancel()`, and is presented again on every reconnect. A refusal throws with the relay's code. +- **Authorization.** On `moq-lite-07-wip` (opt in with `Client(versions:)`) and on moq-transport draft-17+ with the [MoQ Auth extension](/draft/moq-auth), `session.auth().grant()` is the `Grant` the relay sent back, `nil` until it answers and while a client session reconnects; `try await auth.grantChanged()` waits for the next one. `try await auth.add(token:)` presents a fresh token without reconnecting, returns an `AuthToken` whose grant joins the union until `cancel()`, and is presented again on every reconnect. A refusal throws with the relay's code. - **Errors.** Diagnostic cases use the `reason:` argument label; match their associated value to inspect it. Protocol errors carry `details`. - **Disabled renditions.** Audio and video convenience decoders reject a catalog configuration with `enabled: false`. Select an enabled rendition from the latest catalog before opening a decoder. - **Track demand.** Obtain a dynamic track request’s `demand()` before accepting it to watch subscribers through the handoff to its producer. The watch does not keep the track alive and ends when the track closes or the request is rejected. diff --git a/go/wrapper/session.go b/go/wrapper/session.go index 21c9f3419b0..91eadb3e4ab 100644 --- a/go/wrapper/session.go +++ b/go/wrapper/session.go @@ -74,8 +74,9 @@ func (s *Session) Bandwidth() *Bandwidth { } // Auth is the session's authorization: the grant its peer gave it, and more -// tokens to present. A client session's handle survives reconnects, presenting -// each added token again on every new connection. +// tokens to present. Each call returns an independent watcher of the grant. A +// client session's handle survives reconnects, presenting each added token again +// on every new connection. func (s *Session) Auth() *Auth { return &Auth{inner: s.inner.Auth()} } @@ -95,7 +96,8 @@ func (a *Auth) Grant() *Grant { } // GrantChanged blocks until the grant differs from the one this handle last -// reported, and returns it. It errors once the session ends. +// reported, or held when it was made, and returns it. It errors once the session +// ends. func (a *Auth) GrantChanged(ctx context.Context) (*Grant, error) { grant, err := bridge.Call(ctx, nil, a.inner.GrantChanged) return grantFromFFI(grant), err diff --git a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Flows.kt b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Flows.kt index 8521351ab68..0b9000452ef 100644 --- a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Flows.kt +++ b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Flows.kt @@ -4,7 +4,6 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.onCompletion import uniffi.moq.MoqException @@ -59,7 +58,8 @@ fun AnnounceConsumer.updates(): Flow = pull { next() }.onCompleti /** * The session's grant: the current one, then each change, null while there is none. * - * Completes when the session shuts down, and throws its close reason otherwise. + * Collections of one [Auth] share its place in the changes, so a second watcher takes + * its own from [Moq.auth]. Completes when the session shuts down, and throws its close reason otherwise. */ fun Auth.grants(): Flow = flow { emit(grant()) @@ -73,7 +73,7 @@ fun Auth.grants(): Flow = flow { } emit(grant) } -}.distinctUntilChanged() +} /** Emits [next] until it returns null, checking for cancellation before each pull. */ private fun pull(next: suspend () -> T?): Flow = flow { diff --git a/py/moq-rs/README.md b/py/moq-rs/README.md index 28bb9f551df..0d5ed00eed2 100644 --- a/py/moq-rs/README.md +++ b/py/moq-rs/README.md @@ -158,7 +158,7 @@ client = moq.Client( - `.stats() → ConnectionStats`. Snapshot RTT, bandwidth estimates, and byte/packet counters. - `await .status() → ConnectionStatus`, `.connects()`, `.failure()`. Watch reconnects; `connects()` counts connections, 1 on the first, and `failure()` says why a reconnect is under way. - `.bandwidth() → Bandwidth`. Divide the send estimate between encoders and app-owned tracks. - - `.auth() → Auth`. On `moq-lite-07-wip`, `.grant()` reads the peer's `Grant` and `await .add(token) → AuthToken` presents a fresh token without reconnecting. + - `.auth() → Auth`. On `moq-lite-07-wip` and on moq-transport draft-17+ with the MoQ Auth extension, `.grant()` reads the peer's `Grant` and `await .add(token) → AuthToken` presents a fresh token without reconnecting. ### Publishing diff --git a/py/moq-rs/moq/session.py b/py/moq-rs/moq/session.py index f952e6bcb60..c195b72af4b 100644 --- a/py/moq-rs/moq/session.py +++ b/py/moq-rs/moq/session.py @@ -110,8 +110,9 @@ def stats(self) -> ConnectionStats: def auth(self) -> Auth: """The session's authorization: the grant its peer gave it, and more tokens to present. - A client session's handle survives reconnects, presenting each added token - again on every new connection.""" + Each call returns an independent watcher of the grant. A client session's + handle survives reconnects, presenting each added token again on every new + connection.""" return Auth(self._inner.auth()) def recv_bandwidth(self) -> Bandwidth.Consumer | None: @@ -151,7 +152,8 @@ def grant(self) -> Grant | None: return Grant._from_ffi(self._inner.grant()) async def grant_changed(self) -> Grant | None: - """Wait for the grant to differ from the one last reported, returning it. + """Wait for the grant to differ from the one last reported, or held when this + handle was made, returning it. Raises once nothing will change it again, because the session ended.""" return Grant._from_ffi(await self._inner.grant_changed()) diff --git a/rs/moq-ffi/src/auth.rs b/rs/moq-ffi/src/auth.rs index 5afbfa1602a..3303e3f7512 100644 --- a/rs/moq-ffi/src/auth.rs +++ b/rs/moq-ffi/src/auth.rs @@ -51,16 +51,27 @@ impl Handle { } async fn add(self, token: String) -> Result { - match self { - Self::Session(handle) => Ok(Token::Session(handle.add(token).await?)), + let token = match self { + Self::Session(handle) => Token::Session(handle.add(token).await?), #[cfg(not(target_arch = "wasm32"))] - Self::Connection(handle) => Ok(Token::Connection( - handle.add(token).await.map_err(crate::session::map_closed_error)?, - )), + Self::Connection(handle) => Token::Connection(handle.add(token).await.map_err(crate::session::map_closed_error)?), + }; + // A token the peer accepted carries a grant until it ends. Without AUTH, moq-transport + // accepts one at once for the requests that name it, but nothing here can name one, so + // it would never be sent. One already revoked is left for `closed` to report. + if token.grant().peek().is_none() && poll_now(token.closed()).is_pending() { + return Err(MoqError::Unsupported); } + Ok(token) } } +/// Poll `future` once without waiting. +fn poll_now(future: F) -> std::task::Poll { + let future = std::pin::pin!(future); + std::future::Future::poll(future, &mut std::task::Context::from_waker(std::task::Waker::noop())) +} + enum Watch { Session(moq_net::auth::Watch), #[cfg(not(target_arch = "wasm32"))] @@ -125,15 +136,19 @@ pub struct MoqAuth { /// The watch behind [`grant`](Self::grant), which reads without the lock. peek: Watch, /// The watch behind [`grant_changed`](Self::grant_changed), which tracks what this - /// handle last reported. + /// handle last reported. Concurrent callers share it, so each watcher takes its own handle. changed: Task, } impl MoqAuth { pub(crate) fn new(handle: Handle) -> Self { + // Start from the grant as it is now, so a watcher reads that with `grant` and waits + // in `grant_changed` only for what follows, never for the same grant twice. + let mut changed = handle.grant(); + let _ = poll_now(changed.changed()); Self { peek: handle.grant(), - changed: Task::new(handle.grant()), + changed: Task::new(changed), handle, } } @@ -149,7 +164,11 @@ impl MoqAuth { self.peek.peek() } - /// Wait for the grant to differ from the one this handle last reported, returning it. + /// Wait for the grant to differ from the one this handle last reported, or held when it was + /// made, returning it. + /// + /// Calls on one handle share what it last reported; an independent watcher takes its own + /// handle from [`MoqSession::auth`](crate::session::MoqSession::auth). /// /// Errors once nothing will change it again, because the session ended. pub async fn grant_changed(&self) -> Result, MoqError> { @@ -163,8 +182,8 @@ impl MoqAuth { /// /// # Errors /// - /// [`MoqError::Unsupported`] when the session cannot carry another token, such as a - /// moq-lite version without AUTH. + /// [`MoqError::Unsupported`] when the session cannot carry another token: a version + /// without AUTH, or moq-transport without the MoQ Auth extension. pub async fn add(&self, token: String) -> Result, MoqError> { let handle = self.handle.clone(); let token = crate::ffi::detached(handle.add(token)).await?; diff --git a/rs/moq-ffi/src/session.rs b/rs/moq-ffi/src/session.rs index 6a37437104a..458fb83f422 100644 --- a/rs/moq-ffi/src/session.rs +++ b/rs/moq-ffi/src/session.rs @@ -891,7 +891,6 @@ pub struct MoqSession { /// One allocator for the session. Every [`bandwidth`](Self::bandwidth) handle /// clones it, so they share one reservation registry. bandwidth: Arc, - auth: Arc, } impl MoqSession { @@ -945,13 +944,7 @@ impl MoqSession { let publisher = Arc::new(MoqOriginProducer::from_inner(publish)); let consumer = Arc::new(MoqOriginConsumer::from_inner(subscribe)); let bandwidth = Arc::new(MoqBandwidth::new(Self::mint_allocator(&inner))); - let auth = Arc::new(MoqAuth::new(match &inner { - #[cfg(not(target_arch = "wasm32"))] - Inner::Connection(connection) => crate::auth::Handle::Connection(connection.auth()), - Inner::Session(session) => crate::auth::Handle::Session(session.auth()), - })); Self { - auth, inner: inner.clone(), closed: Task::new(inner.clone()), status: Task::new(inner), @@ -1109,10 +1102,16 @@ impl MoqSession { /// The session's authorization: the grant its peer gave it, and more tokens to present. /// - /// Every call returns the same handle. A client session's handle survives reconnects, - /// presenting each added token again on every new connection. + /// Every call returns a new handle sharing the session's tokens, which tracks its own + /// [`grant_changed`](MoqAuth::grant_changed), so concurrent watchers each take one. A client + /// session's handle survives reconnects, presenting each added token again on every new + /// connection. pub fn auth(&self) -> Arc { - self.auth.clone() + Arc::new(MoqAuth::new(match &self.inner { + #[cfg(not(target_arch = "wasm32"))] + Inner::Connection(connection) => crate::auth::Handle::Connection(connection.auth()), + Inner::Session(session) => crate::auth::Handle::Session(session.auth()), + })) } /// Hold receive-estimate demand until the returned consumer is released. diff --git a/rs/moq-ffi/src/test.rs b/rs/moq-ffi/src/test.rs index a7a3b5de3a8..33dc6697a34 100644 --- a/rs/moq-ffi/src/test.rs +++ b/rs/moq-ffi/src/test.rs @@ -6581,10 +6581,15 @@ async fn data_track_names_cannot_collide() { /// Real sockets exercise the FFI runtime and both accepted/client session handles. /// Only lite-07 acknowledges the received tail; default lite-06 closes after transport ACKs. async fn shutdown_pair() -> (Arc, Arc, Arc) { + session_pair("moq-lite-07-wip").await +} + +/// A server, a client session connected to it on `version`, and the session it accepted. +async fn session_pair(version: &str) -> (Arc, Arc, Arc) { let server = MoqServer::new(MoqServerConfig { bind: Some("127.0.0.1:0".into()), tls: localhost_tls(), - versions: vec!["moq-lite-07-wip".into()], + versions: vec![version.into()], ..Default::default() }) .unwrap(); @@ -6602,7 +6607,7 @@ async fn shutdown_pair() -> (Arc, Arc, Arc) { }); let client = MoqClient::new(MoqClientConfig { tls: insecure_tls(), - versions: vec!["moq-lite-07-wip".into()], + versions: vec![version.into()], bind: Some("127.0.0.1:0".into()), once: true, ..Default::default() @@ -6792,12 +6797,20 @@ fn grant(publish: &[&str], subscribe: &[&str]) -> crate::auth::MoqGrant { } } -/// Both sides of a session read the grant their peer gave them, and a peer that verifies no -/// tokens of its own refuses another one as unsupported. +/// Both sides of a session read the grant their peer gave them, on lite-07 and on moq-transport +/// with the Auth extension, and a peer that verifies no tokens of its own refuses another one as +/// unsupported. #[tokio::test] async fn auth_reads_the_peer_grant() { - let (server, client, accepted) = tokio::time::timeout(TIMEOUT, shutdown_pair()).await.expect("pair"); - assert!(Arc::ptr_eq(&client.auth(), &client.auth()), "one handle per session"); + for version in ["moq-lite-07-wip", "moq-transport-17"] { + auth_reads_the_peer_grant_on(version).await; + } +} + +async fn auth_reads_the_peer_grant_on(version: &str) { + let (server, client, accepted) = tokio::time::timeout(TIMEOUT, session_pair(version)) + .await + .expect("pair"); // Each side's origins span the root, so its peer grants it everything. await_grant(&client.auth(), grant(&["**"], &["**"])).await; @@ -6820,6 +6833,28 @@ async fn auth_reads_the_peer_grant() { server.cancel(); } +/// moq-transport without the Auth extension carries a token only on a request that names it, +/// which no binding can, so both sides refuse one rather than report a token nothing sends. +#[tokio::test] +async fn auth_refuses_a_token_nothing_carries() { + let (server, client, accepted) = tokio::time::timeout(TIMEOUT, session_pair("moq-transport-14")) + .await + .expect("pair"); + for session in [&client, &accepted] { + let err = tokio::time::timeout(TIMEOUT, session.auth().add("extra".into())) + .await + .expect("add timed out") + .err() + .expect("a token nothing carries is refused"); + assert!(matches!(err, MoqError::Unsupported), "refusal: {err:?}"); + assert_eq!(session.auth().grant(), None, "no AUTH, no grant"); + } + + client.cancel(0); + accepted.cancel(0); + server.cancel(); +} + /// A token added through a client session joins the union with its own grant, leaves it on /// cancel, and a refusal or revocation carries the peer's code. #[tokio::test] @@ -6866,6 +6901,8 @@ async fn auth_tokens_join_and_leave_the_union() { .unwrap(); let auth = session.auth(); await_grant(&auth, grant(&["setup/**"], &[])).await; + // A second handle watches on its own and starts from the grant it was made with. + let watcher = session.auth(); let extra = tokio::time::timeout(TIMEOUT, auth.add("extra".into())) .await @@ -6874,6 +6911,11 @@ async fn auth_tokens_join_and_leave_the_union() { assert_eq!(extra.grant(), Some(grant(&["extra/**"], &[]))); // Patterns are kept sorted. await_grant(&auth, grant(&["extra/**", "setup/**"], &[])).await; + let seen = tokio::time::timeout(TIMEOUT, watcher.grant_changed()) + .await + .expect("the second handle sees the change the first took") + .unwrap(); + assert_eq!(seen, Some(grant(&["extra/**", "setup/**"], &[]))); extra.cancel(); await_grant(&auth, grant(&["setup/**"], &[])).await; diff --git a/rs/moq-tokio/src/connection/auth.rs b/rs/moq-tokio/src/connection/auth.rs index f6e5a194b24..2fb3c278df4 100644 --- a/rs/moq-tokio/src/connection/auth.rs +++ b/rs/moq-tokio/src/connection/auth.rs @@ -352,13 +352,14 @@ async fn present( Err(err) => return Err(err.into()), } }; - if let Some(answered) = answered.take() { - let _ = answered.send(Ok(())); - } let mut watch = token.grant(); if let Ok(mut state) = producer.write() { state.grant = watch.peek(); } + // After the grant, so `add` never returns a token that still reads none. + if let Some(answered) = answered.take() { + let _ = answered.send(Ok(())); + } loop { tokio::select! { biased; diff --git a/swift/Sources/Moq/Auth.swift b/swift/Sources/Moq/Auth.swift index 11a2d6700e0..6bca59e7aef 100644 --- a/swift/Sources/Moq/Auth.swift +++ b/swift/Sources/Moq/Auth.swift @@ -19,7 +19,8 @@ public final class Auth: Sendable { /// A client session reads `nil` while it reconnects. public func grant() -> Grant? { ffi.grant() } - /// Suspend until the grant differs from the one last reported, returning it. + /// Suspend until the grant differs from the one last reported, or held when this + /// handle was made, returning it. /// Throws once nothing will change it again, because the session ended. public func grantChanged() async throws -> Grant? { try await ffi.grantChanged() } diff --git a/swift/Sources/Moq/Client.swift b/swift/Sources/Moq/Client.swift index c13ee718e69..8c8a70b0b24 100644 --- a/swift/Sources/Moq/Client.swift +++ b/swift/Sources/Moq/Client.swift @@ -158,8 +158,9 @@ public final class Session: Sendable { /// The session's authorization: the grant its peer gave it, and more tokens to present. /// - /// A client session's handle survives reconnects, presenting each added token - /// again on every new connection. + /// Each call returns an independent watcher of the grant. A client session's + /// handle survives reconnects, presenting each added token again on every new + /// connection. public func auth() -> Auth { Auth(ffi.auth()) } diff --git a/swift/Tests/MoqTests/SmokeTests.swift b/swift/Tests/MoqTests/SmokeTests.swift index 780e04fdc56..c2c06147bc7 100644 --- a/swift/Tests/MoqTests/SmokeTests.swift +++ b/swift/Tests/MoqTests/SmokeTests.swift @@ -116,6 +116,15 @@ final class SmokeTests: XCTestCase { guard let request = try await accepted else { return XCTFail("server closed before accept") } let peer = try await request.accept() + // Ending both sessions ends any wait below, so a grant that never arrives fails + // rather than hangs. + let watchdog = Task { + try await Task.sleep(nanoseconds: 10_000_000_000) + peer.cancel(code: 0) + session.cancel(code: 0) + } + defer { watchdog.cancel() } + let everything = Grant(publish: ["**"], subscribe: ["**"], expiresUs: nil) for auth in [session.auth(), peer.auth()] { var grant = auth.grant() diff --git a/test/interop/interop.sh b/test/interop/interop.sh index 83ed46ecff9..59ecb277ef2 100755 --- a/test/interop/interop.sh +++ b/test/interop/interop.sh @@ -676,13 +676,13 @@ run_publisher() { ;; python) ffmpeg_h264 | "$PY" "$CLIENTS/python/interop.py" \ - publish --url "$url" --broadcast "$broadcast" "${auth[@]}" + publish --url "$url" --broadcast "$broadcast" ${auth[@]+"${auth[@]}"} ;; go) - ffmpeg_h264 | "$GO_INTEROP" publish --url "$url" --broadcast "$broadcast" "${auth[@]}" + ffmpeg_h264 | "$GO_INTEROP" publish --url "$url" --broadcast "$broadcast" ${auth[@]+"${auth[@]}"} ;; cpp) - ffmpeg_h264 | "$CPP_INTEROP" publish --url "$url" --broadcast "$broadcast" "${auth[@]}" + ffmpeg_h264 | "$CPP_INTEROP" publish --url "$url" --broadcast "$broadcast" ${auth[@]+"${auth[@]}"} ;; js-native) # exec, so the client leads the group and `stop_publisher` waits for its own close. @@ -825,13 +825,13 @@ PYCODE ;; python) "$PY" "$CLIENTS/python/interop.py" \ - subscribe --url "$url" --broadcast "$broadcast" --timeout "$TIMEOUT" "${auth[@]}" + subscribe --url "$url" --broadcast "$broadcast" --timeout "$TIMEOUT" ${auth[@]+"${auth[@]}"} ;; go) - "$GO_INTEROP" subscribe --url "$url" --broadcast "$broadcast" --timeout "$TIMEOUT" "${auth[@]}" + "$GO_INTEROP" subscribe --url "$url" --broadcast "$broadcast" --timeout "$TIMEOUT" ${auth[@]+"${auth[@]}"} ;; cpp) - "$CPP_INTEROP" subscribe --url "$url" --broadcast "$broadcast" --timeout "$TIMEOUT" "${auth[@]}" + "$CPP_INTEROP" subscribe --url "$url" --broadcast "$broadcast" --timeout "$TIMEOUT" ${auth[@]+"${auth[@]}"} ;; c) "$C_INTEROP" subscribe --url "$url" --broadcast "$broadcast" --timeout "$TIMEOUT" From 96fed41927599a4765b5f2200bf4c46723605cef Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 20 Oct 2026 20:34:40 -0700 Subject: [PATCH 23/45] chore(dart): regenerate bindings Co-Authored-By: Claude Opus 5.5 --- dart/moq_ffi/lib/src/moq.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dart/moq_ffi/lib/src/moq.dart b/dart/moq_ffi/lib/src/moq.dart index 1ce99f516fd..8aefb4ef3e0 100644 --- a/dart/moq_ffi/lib/src/moq.dart +++ b/dart/moq_ffi/lib/src/moq.dart @@ -18966,13 +18966,13 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_method_moqaudioproducer_write() != 22094) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqauth_add() != 43924) { + if (uniffi_moq_ffi_checksum_method_moqauth_add() != 1074) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqauth_grant() != 14588) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqauth_grant_changed() != 50699) { + if (uniffi_moq_ffi_checksum_method_moqauth_grant_changed() != 5195) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqauthtoken_cancel() != 59324) { @@ -19406,7 +19406,7 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_method_moqclient_connect() != 18055) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqsession_auth() != 18740) { + if (uniffi_moq_ffi_checksum_method_moqsession_auth() != 1680) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqsession_bandwidth() != 8006) { From 32161b32bfcab3eb0360f8c6b8d097f772d7e13a Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 20 Oct 2026 21:37:24 -0700 Subject: [PATCH 24/45] fix(tokio): a token the peer closes cleanly ends without an error moq-net reports a clean close as Cancel, which the connection's token task surfaced as an error, so client sessions in the bindings saw Cancelled where server sessions saw success. Co-Authored-By: Claude Opus 5.5 --- rs/moq-ffi/src/test.rs | 12 ++++++++++++ rs/moq-tokio/src/connection/auth.rs | 5 ++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/rs/moq-ffi/src/test.rs b/rs/moq-ffi/src/test.rs index 33dc6697a34..7555ee8b39b 100644 --- a/rs/moq-ffi/src/test.rs +++ b/rs/moq-ffi/src/test.rs @@ -6882,6 +6882,8 @@ async fn auth_tokens_join_and_leave_the_union() { b"revoked" => request .accept(publish("revoked/**")) .revoke(moq_net::SessionError::Expired, "revoked"), + // Dropping the issued grant closes the token without an error. + b"closed" => drop(request.accept(publish("closed/**"))), _ => request.reject(moq_net::SessionError::Unauthorized, "unknown token"), } } @@ -6944,6 +6946,16 @@ async fn auth_tokens_join_and_leave_the_union() { assert_eq!(revoked.grant(), None, "an ended token grants nothing"); assert_eq!(auth.grant(), Some(grant(&["setup/**"], &[]))); + let closed = tokio::time::timeout(TIMEOUT, auth.add("closed".into())) + .await + .expect("add timed out") + .unwrap(); + tokio::time::timeout(TIMEOUT, closed.closed()) + .await + .expect("closed timed out") + .expect("a clean close is not an error"); + assert_eq!(auth.grant(), Some(grant(&["setup/**"], &[]))); + session.cancel(0); tokio::time::timeout(TIMEOUT, serving) .await diff --git a/rs/moq-tokio/src/connection/auth.rs b/rs/moq-tokio/src/connection/auth.rs index 2fb3c278df4..21a91e94768 100644 --- a/rs/moq-tokio/src/connection/auth.rs +++ b/rs/moq-tokio/src/connection/auth.rs @@ -158,7 +158,8 @@ impl Auth { } } - /// Wait until the token is refused or its connection ends. + /// Wait until the token ends: `Ok` when the peer closed it without an error, otherwise its + /// refusal or revocation, or the connection's end. pub async fn closed(&self) -> crate::Result<()> { kio::wait(|waiter| self.state.poll_closed(waiter)).await; match &self.state.read().error { @@ -379,6 +380,8 @@ async fn present( snapshot = next(&connection, Some((connects, true))).await?; break; } + // The peer closed it without an error: the token is over, not refused. + Err(moq_net::Error::Cancel) => return Ok(()), Err(err) => return Err(err.into()), } } From c8db840240c9cce847890680d4bb2aa9a7248a95 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 20 Oct 2026 22:01:06 -0700 Subject: [PATCH 25/45] fix(tokio,obs): refuse an unsendable token at its source, keep token notices - moq-tokio's connection refuses a token as unsupported when its session accepts it without AUTH, so a reconnect between the answer and moq-ffi's check can no longer turn an accepted token into Unsupported. - The OBS dock keeps why a token edit was not presented until the next one, instead of losing it on the next status refresh. Co-Authored-By: Claude Opus 5.5 --- cpp/obs/src/moq-dock.cpp | 22 +++++++++++++++------- cpp/obs/src/moq-dock.h | 2 ++ rs/moq-ffi/src/auth.rs | 25 +++++++++++++++---------- rs/moq-tokio/src/connection/auth.rs | 8 ++++++++ 4 files changed, 40 insertions(+), 17 deletions(-) diff --git a/cpp/obs/src/moq-dock.cpp b/cpp/obs/src/moq-dock.cpp index 4d01c56fdb1..d3eab4a3488 100644 --- a/cpp/obs/src/moq-dock.cpp +++ b/cpp/obs/src/moq-dock.cpp @@ -690,16 +690,19 @@ void MoQDock::OnTokenEdited() // may be new. const QUrl dial(urlEdit->text().trimmed()); if (dial.isValid() && MoQDial::IsCleartext(dial.scheme().toStdString())) { - status->setText("Publish token requires an encrypted relay URL (not http://, ws://, or tcp://)"); + tokenNotice = "Publish token requires an encrypted relay URL (not http://, ws://, or tcp://)"; + status->setText(tokenNotice); return; } // Presented in band, so the stream keeps going while the relay checks it. auto *moq = static_cast(obs_obj_get_data(output)); if (!moq || !moq->PresentToken(token.toStdString())) { - status->setText("Not connected yet; edit the token again once live"); + tokenNotice = "Not connected yet; edit the token again once live"; + status->setText(tokenNotice); return; } + tokenNotice.clear(); tokenEdited = false; } @@ -1098,6 +1101,7 @@ void MoQDock::StartStream() const std::string url = ConnectUrl().toStdString(); const std::string path = pathEdit->text().toStdString(); tokenEdited = false; + tokenNotice.clear(); SaveSettings(); @@ -1288,20 +1292,24 @@ void MoQDock::UpdateStatus() return; } + // Why the last token edit did not take, so the next refresh does not hide it. + QString tokenProblem = tokenNotice; const QString tokenRefusal = QString::fromStdString(moq->TokenRefusal()); + if (tokenProblem.isEmpty() && !tokenRefusal.isEmpty()) + tokenProblem = QString("New publish token refused: %1").arg(tokenRefusal); QString connectedText = stats.reconnects > 0 ? QString("● Connected · reconnects %1").arg(stats.reconnects) : QString("● Connected"); - if (!tokenRefusal.isEmpty()) - connectedText += QStringLiteral(" · new token refused"); + if (!tokenProblem.isEmpty()) + connectedText += QStringLiteral(" · new token not in use"); status->setText(connectedText); - status->setStyleSheet(tokenRefusal.isEmpty() ? "color: #36a45e;" : "color: #d08b1d;"); + status->setStyleSheet(tokenProblem.isEmpty() ? "color: #36a45e;" : "color: #d08b1d;"); { QStringList lines; if (!publishSummary.isEmpty()) lines << publishSummary; - if (!tokenRefusal.isEmpty()) - lines << QString("New publish token refused: %1").arg(tokenRefusal); + if (!tokenProblem.isEmpty()) + lines << tokenProblem; if (!stats.protocol.empty()) lines << QString("Protocol %1").arg(QString::fromStdString(stats.protocol)); if (!stats.dial.empty()) diff --git a/cpp/obs/src/moq-dock.h b/cpp/obs/src/moq-dock.h index 393490c5df4..3beb13300fa 100644 --- a/cpp/obs/src/moq-dock.h +++ b/cpp/obs/src/moq-dock.h @@ -100,6 +100,8 @@ private slots: // Whether the token field was typed in since its token was last presented, so // leaving it unchanged does not present it again while a retyped one does. bool tokenEdited = false; + // Why the last token edit was not presented, kept until the next one is. + QString tokenNotice; // Filled when Go Live creates encoders; shown in Stats while publishing. QString publishSummary; QString latencySummary; diff --git a/rs/moq-ffi/src/auth.rs b/rs/moq-ffi/src/auth.rs index 3303e3f7512..f191e07e853 100644 --- a/rs/moq-ffi/src/auth.rs +++ b/rs/moq-ffi/src/auth.rs @@ -51,18 +51,23 @@ impl Handle { } async fn add(self, token: String) -> Result { - let token = match self { - Self::Session(handle) => Token::Session(handle.add(token).await?), + match self { + Self::Session(handle) => { + let auth = handle.add(token).await?; + // A token the peer accepted carries a grant until it ends. Without AUTH, + // moq-transport accepts one at once for the requests that name it, but nothing + // here can name one, so it would never be sent. One already revoked is left for + // `closed` to report. moq-tokio refuses the same for a client's connection. + if auth.grant().peek().is_none() && poll_now(auth.closed()).is_pending() { + return Err(MoqError::Unsupported); + } + Ok(Token::Session(auth)) + } #[cfg(not(target_arch = "wasm32"))] - Self::Connection(handle) => Token::Connection(handle.add(token).await.map_err(crate::session::map_closed_error)?), - }; - // A token the peer accepted carries a grant until it ends. Without AUTH, moq-transport - // accepts one at once for the requests that name it, but nothing here can name one, so - // it would never be sent. One already revoked is left for `closed` to report. - if token.grant().peek().is_none() && poll_now(token.closed()).is_pending() { - return Err(MoqError::Unsupported); + Self::Connection(handle) => Ok(Token::Connection( + handle.add(token).await.map_err(crate::session::map_closed_error)?, + )), } - Ok(token) } } diff --git a/rs/moq-tokio/src/connection/auth.rs b/rs/moq-tokio/src/connection/auth.rs index 21a91e94768..ea686213d3a 100644 --- a/rs/moq-tokio/src/connection/auth.rs +++ b/rs/moq-tokio/src/connection/auth.rs @@ -29,6 +29,8 @@ impl Handle { } /// Present a token now and on later sessions until its handle is dropped or refused. + /// + /// A session that cannot send it, without AUTH, refuses it as unsupported. pub async fn add(&self, token: impl Into) -> crate::Result { let producer = kio::Producer::::default(); let state = producer.consume(); @@ -354,6 +356,12 @@ async fn present( } }; let mut watch = token.grant(); + // Without AUTH, moq-transport accepts a token at once for the requests that name it, + // but a connection's token rides no request, so nothing would send it. One the peer + // already ended has a grant no longer and is left to the watch below. + if watch.peek().is_none() && token.closed().now_or_never().is_none() { + return Err(moq_net::Error::Unsupported.into()); + } if let Ok(mut state) = producer.write() { state.grant = watch.peek(); } From affca2bcf6f0f58bbe02f2a747419aab460b88d0 Mon Sep 17 00:00:00 2001 From: Melanie Riise Date: Tue, 20 Oct 2026 19:55:34 -0700 Subject: [PATCH 26/45] feat(net): present the connection credential in SETUP lite-07-wip gains a client-only Token parameter (0x6) that fills server::Handshake::token, and moq-transport clients now write the AUTHORIZATION TOKEN setup option. Client::with_token sets it. setup::Token's Debug no longer prints the credential. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/client.rs | 34 ++++++++++- rs/moq-net/src/ietf/session.rs | 47 ++++++++++++++- rs/moq-net/src/ietf/token.rs | 1 - rs/moq-net/src/lite/setup.rs | 88 +++++++++++++++++++++++++++- rs/moq-net/src/server.rs | 18 ++++-- rs/moq-net/src/setup.rs | 12 +++- rs/moq-net/tests/integration/auth.rs | 60 ++++++++++++++++++- 7 files changed, 243 insertions(+), 17 deletions(-) diff --git a/rs/moq-net/src/client.rs b/rs/moq-net/src/client.rs index fa06045ed8b..a3b212582b2 100644 --- a/rs/moq-net/src/client.rs +++ b/rs/moq-net/src/client.rs @@ -16,6 +16,7 @@ pub struct Client { versions: Versions, setup_path: Option, setup_authority: Option, + setup_token: Option, cost: Option, peer_hop: Option, limits: crate::session::Limits, @@ -95,6 +96,19 @@ impl Client { self } + /// Present `token` as the connection credential in SETUP, on versions that carry one + /// (moq-lite-07+ and every moq-transport draft we speak). The session's empty-token + /// AUTH stream then answers with its grant. + /// + /// Other versions send nothing, so a caller that may negotiate one also puts the + /// token in the URL query. moq-lite carries no Token Type, so only + /// [`Token::OUT_OF_BAND`](setup::Token::OUT_OF_BAND) reaches it; another type fails + /// the handshake. + pub fn with_token(mut self, token: setup::Token) -> Self { + self.setup_token = Some(token); + self + } + /// Choose which extensions to offer in SETUP. Defaults to every moq-transport one, and /// not [`ietf`](setup::Extensions::ietf), which only relays turn on. pub fn with_extensions(mut self, extensions: setup::Extensions) -> Self { @@ -201,6 +215,8 @@ impl Client { cost: self.cost, // Filled by `lite::start` from the attached origin handles. hop: None, + // Older versions carry the credential in the URL query instead. + token: self.setup_token.clone().filter(|_| version.has_auth()), ietf: self.extensions.ietf && version.has_ietf(), } } else { @@ -308,6 +324,7 @@ impl Client { version: draft, path: self.setup_path.clone(), authority: self.setup_authority.clone(), + token: self.setup_token.clone(), peer_setup_stream: None, peer_declared: None, auth: auth.clone(), @@ -398,6 +415,9 @@ impl Client { if let Some(authority) = &self.setup_authority { parameters.set_bytes(ietf::ParameterBytes::Authority, authority.clone().into_bytes()); } + if let Some(token) = &self.setup_token { + ietf::token::into_setup(&mut parameters, token, ietf_encoding)?; + } if self.extensions.solicit { ietf::solicit::into_setup(&mut parameters, ietf_encoding); } @@ -483,6 +503,7 @@ impl Client { version: v, path: None, authority: None, + token: None, peer_setup_stream: None, peer_declared: Some(peer_declared), auth: auth.clone(), @@ -851,7 +872,11 @@ mod tests { /// The client SETUP on the bidi control stream (the pre-draft-17 framing) carries the /// AUTHORITY next to the PATH. #[moq_clock::test] - async fn draft14_setup_carries_the_authority() { + async fn draft14_setup_carries_the_authority_and_token() { + let token = setup::Token { + kind: setup::Token::OUT_OF_BAND, + value: b"connection-credential".to_vec(), + }; let fake = FakeSession::new(Some(ALPN_LITE), mock_server_setup(Version::Lite(lite::Version::Lite01))); let client = Client::new() .with_versions( @@ -862,7 +887,8 @@ mod tests { .into(), ) .with_path("/anon") - .with_authority("relay.example.com:4443"); + .with_authority("relay.example.com:4443") + .with_token(token.clone()); let (_session, driver) = client.connect(moq_clock::now(), fake.clone()).await.unwrap(); moq_clock::sim::spawn(crate::time::run(driver)); @@ -878,6 +904,10 @@ mod tests { parameters.get_bytes(ietf::ParameterBytes::Path), Some(b"/anon".as_ref()) ); + assert_eq!( + ietf::token::from_setup(¶meters, ietf::Version::Draft14).unwrap(), + Some(token) + ); } #[moq_clock::test] diff --git a/rs/moq-net/src/ietf/session.rs b/rs/moq-net/src/ietf/session.rs index 8c7b01bd03e..6ec36ba6916 100644 --- a/rs/moq-net/src/ietf/session.rs +++ b/rs/moq-net/src/ietf/session.rs @@ -63,6 +63,10 @@ pub struct Config { /// The URI authority we advertise in our SETUP, under the same rules as `path`. pub authority: Option, + /// The connection credential our SETUP's `AUTHORIZATION TOKEN` option presents. A + /// server passes `None`. + pub token: Option, + /// The peer's SETUP stream, when it was already read before [`start`] (a draft-17+ /// server that gated on the client's path via [`accept_setup`]). It becomes the /// GOAWAY channel; `None` lets the uni loop read the SETUP itself. @@ -127,6 +131,7 @@ where version, path, authority, + token, peer_setup_stream, peer_declared, auth, @@ -398,6 +403,7 @@ where version, path, authority, + token, self_origin, cost, crate::setup::Extensions { @@ -765,7 +771,8 @@ fn peer_from_params(params: &ietf::Parameters, version: Version) -> Result( version: Version, path: Option, authority: Option, + token: Option, self_origin: Hop, cost: Option, extensions: crate::setup::Extensions, @@ -797,6 +805,9 @@ async fn run_setup( if let Some(authority) = authority { parameters.set_bytes(ietf::ParameterBytes::Authority, authority.into_bytes()); } + if let Some(token) = &token { + super::token::into_setup(&mut parameters, token, version)?; + } cluster::peer_into_setup(&mut parameters, self_origin, cost, version); if extensions.solicit { solicit::into_setup(&mut parameters, version); @@ -1578,6 +1589,7 @@ mod tests { version, path: None, authority: None, + token: None, peer_setup_stream: None, peer_declared: Some(peer::Peer { auth: true, @@ -1633,6 +1645,7 @@ mod tests { version, path: None, authority: None, + token: None, peer_setup_stream: None, peer_declared: Some(peer::Peer { auth: true, @@ -1679,6 +1692,7 @@ mod tests { version, path: None, authority: None, + token: None, peer_setup_stream: None, peer_declared: Some(peer::Peer { auth: true, @@ -1773,6 +1787,7 @@ mod tests { version: VERSION, path: None, authority: None, + token: None, peer_setup_stream: None, // A peer that declared its Hop ID negotiated the extension, which is what // makes the cluster parameters mandatory in both directions. @@ -1836,6 +1851,7 @@ mod tests { version: Version::Draft18, path: None, authority: None, + token: None, peer_setup_stream: None, // The requests wait on the peer's SETUP (MoQ Hidden). peer_declared: Some(peer::Peer::default()), @@ -1909,6 +1925,7 @@ mod tests { version, path: None, authority: None, + token: None, peer_setup_stream: None, peer_declared: Some(peer::Peer { solicit, @@ -1997,6 +2014,7 @@ mod tests { version: Version::Draft18, path: None, authority: None, + token: None, peer_setup_stream: None, peer_declared, auth: crate::auth::Handle::new(false), @@ -2100,6 +2118,7 @@ mod tests { version: Version::Draft18, path: None, authority: None, + token: None, peer_setup_stream: None, peer_declared: Some(peer::Peer { auth, @@ -2184,6 +2203,7 @@ mod tests { version: VERSION, path: None, authority: None, + token: None, peer_setup_stream: None, peer_declared: Some(peer::Peer::default()), auth, @@ -2280,6 +2300,7 @@ mod tests { version: VERSION, path: None, authority: None, + token: None, peer_setup_stream: None, peer_declared: Some(peer::Peer { auth: true, @@ -2369,6 +2390,7 @@ mod tests { version: VERSION, path: None, authority: None, + token: None, peer_setup_stream: None, // Pre-settled, so nothing waits on a SETUP the dead stream will never // carry and the dispatch loop actually runs. @@ -2386,10 +2408,12 @@ mod tests { assert_eq!(log.closes(), vec![], "nothing may close the transport"); } - /// A draft-17+ client advertises its AUTHORITY in the SETUP it writes on its uni stream. + /// A draft-17+ client advertises its AUTHORITY and connection credential in the SETUP + /// it writes on its uni stream. #[moq_clock::test] - async fn setup_carries_the_authority() { + async fn setup_carries_the_authority_and_token() { const AUTHORITY: &[u8] = b"relay.example.com:4443"; + const TOKEN: &[u8] = b"connection-credential"; // The driver parks forever once SETUP is out, so bound it: paused time makes the // deadline fire the moment nothing else can run. @@ -2412,6 +2436,10 @@ mod tests { version, path: None, authority: Some(String::from_utf8(AUTHORITY.to_vec()).unwrap()), + token: Some(crate::setup::Token { + kind: crate::setup::Token::OUT_OF_BAND, + value: TOKEN.to_vec(), + }), peer_setup_stream: None, peer_declared: Some(peer::Peer::default()), auth: crate::auth::Handle::new(false), @@ -2429,6 +2457,11 @@ mod tests { 1, "{version:?}: SETUP must carry the authority once" ); + assert_eq!( + occurrences(&log, TOKEN), + 1, + "{version:?}: SETUP must carry the token once" + ); } } @@ -2679,6 +2712,7 @@ mod tests { version: Version::Draft19, path: None, authority: None, + token: None, peer_setup_stream: None, peer_declared: Some(peer::Peer::default()), auth: crate::auth::Handle::new(false), @@ -2822,6 +2856,7 @@ mod tests { version, path: None, authority: None, + token: None, peer_setup_stream: None, peer_declared: Some(peer::Peer::default()), auth: crate::auth::Handle::new(false), @@ -2889,6 +2924,7 @@ mod tests { version, path: None, authority: None, + token: None, peer_setup_stream: None, peer_declared: Some(peer::Peer::default()), auth: crate::auth::Handle::new(false), @@ -2992,6 +3028,7 @@ mod tests { version: VERSION, path: None, authority: None, + token: None, peer_setup_stream: None, peer_declared: None, auth: crate::auth::Handle::new(false), @@ -3114,6 +3151,7 @@ mod tests { version, path: None, authority: None, + token: None, peer_setup_stream: None, peer_declared: Some(peer::Peer::default()), auth: crate::auth::Handle::new(false), @@ -3345,6 +3383,7 @@ mod tests { version: VERSION, path: None, authority: None, + token: None, peer_setup_stream: None, peer_declared: None, auth: crate::auth::Handle::new(false), @@ -3408,6 +3447,7 @@ mod tests { VERSION, None, None, + None, Hop::new(1).unwrap(), None, Default::default(), @@ -3447,6 +3487,7 @@ mod tests { version, path: None, authority: None, + token: None, peer_setup_stream: None, peer_declared: Some(peer::Peer::default()), auth: crate::auth::Handle::new(false), diff --git a/rs/moq-net/src/ietf/token.rs b/rs/moq-net/src/ietf/token.rs index 70f80f000e5..2604479881d 100644 --- a/rs/moq-net/src/ietf/token.rs +++ b/rs/moq-net/src/ietf/token.rs @@ -37,7 +37,6 @@ pub fn from_setup(params: &Parameters, version: Version) -> Result } /// Present `token` in our SETUP, by value. -#[cfg_attr(not(test), expect(dead_code))] pub fn into_setup(params: &mut Parameters, token: &Token, version: Version) -> Result<(), EncodeError> { params.set_bytes(ParameterBytes::AuthorizationToken, encode(token, version)?); Ok(()) diff --git a/rs/moq-net/src/lite/setup.rs b/rs/moq-net/src/lite/setup.rs index 52254dd4360..31e00b28bc1 100644 --- a/rs/moq-net/src/lite/setup.rs +++ b/rs/moq-net/src/lite/setup.rs @@ -15,6 +15,8 @@ const PARAM_ROLE: u64 = 0x3; const PARAM_COST: u64 = 0x4; /// Setup Parameter id for the endpoint's Hop ID. const PARAM_HOP: u64 = 0x5; +/// Setup Parameter id for the client's connection credential (client-only, lite-07+). +const PARAM_TOKEN: u64 = 0x6; /// Setup Parameter id offering the `ietf` extension (draft-lcurley-moq-ietf): "IETF" in ASCII. const PARAM_IETF: u64 = 0x4945_5446; @@ -184,6 +186,10 @@ pub struct Setup { /// applies). `None` when the endpoint has no meaningful identity (a /// leaf that never forwards); a wire value of 0 decodes as `None`. pub hop: Option, + /// The connection credential (lite-07+), which the empty-token Auth Stream names. + /// Sent only by the client. The parameter carries no Token Type, so it is always + /// [`Token::OUT_OF_BAND`](crate::setup::Token::OUT_OF_BAND). + pub token: Option, /// Whether this endpoint offers the `ietf` extension (lite-07+). Negotiated only when /// both endpoints offer it. pub ietf: bool, @@ -220,6 +226,16 @@ impl Message for Setup { if let Some(hop) = self.hop { params.set_varint(PARAM_HOP, hop.id(), version)?; } + if let Some(token) = &self.token { + if !version.has_auth() { + return Err(EncodeError::Version); + } + // An empty value is no credential, and the untyped parameter cannot carry another type. + if token.kind != crate::setup::Token::OUT_OF_BAND || token.value.is_empty() { + return Err(EncodeError::Unsupported); + } + params.set_bytes(PARAM_TOKEN, token.value.clone()); + } if self.ietf { // The framing it changes is unpublished, so a published version cannot offer it. if !version.has_ietf() { @@ -268,7 +284,15 @@ impl Message for ReceivedSetup { let hop = params .get_varint(PARAM_HOP, version)? .and_then(|id| crate::Hop::new(id).ok()); - // An older version does not know the parameter, so it ignores it like any other. + // An older version does not know these parameters, so it ignores them like any other. + let token = match params.get_bytes(PARAM_TOKEN) { + Some([]) if version.has_auth() => return Err(DecodeError::InvalidValue), + Some(value) if version.has_auth() => Some(crate::setup::Token { + kind: crate::setup::Token::OUT_OF_BAND, + value: value.to_vec(), + }), + _ => None, + }; let ietf = match params.get_bytes(PARAM_IETF) { Some(value) if version.has_ietf() => match value.is_empty() { true => true, @@ -285,6 +309,7 @@ impl Message for ReceivedSetup { role, cost, hop, + token, ietf, }, }) @@ -326,7 +351,7 @@ impl PeerSetup { pub(super) fn receive(&self, received: ReceivedSetup) -> Result<(), crate::Error> { let mut state = self.0.lock(); if ((state.client || state.binding == crate::transport::Binding::Uri) && received.setup.path.is_some()) - || (state.client && received.role_present) + || (state.client && (received.role_present || received.setup.token.is_some())) { return Err(crate::Error::ProtocolViolation); } @@ -531,6 +556,65 @@ mod tests { assert!(Setup::decode_slice(&valued, Version::Lite07).is_err()); } + /// The connection credential rides lite-07+, which an older version ignores like any + /// unknown parameter, and only an untyped, non-empty token can be sent. + #[test] + fn token_is_lite07_only() { + let token = crate::setup::Token { + kind: crate::setup::Token::OUT_OF_BAND, + value: b"jwt".to_vec(), + }; + let msg = Setup { + token: Some(token.clone()), + ..Default::default() + }; + let buf = msg.encode_bytes(Version::Lite07).unwrap(); + // Length, one parameter, its id, then the token's length and bytes. + assert_eq!(&buf[..], [0x06, 0x01, 0x06, 0x03, b'j', b'w', b't']); + assert_eq!(Setup::decode_slice(&buf, Version::Lite07).unwrap().0, msg); + assert!(matches!(msg.encode_bytes(Version::Lite06), Err(EncodeError::Version))); + assert_eq!(Setup::decode_slice(&buf, Version::Lite06).unwrap().0, Setup::default()); + + for unsendable in [ + crate::setup::Token { + kind: crate::setup::Token::CAT, + ..token.clone() + }, + crate::setup::Token { + value: Vec::new(), + ..token + }, + ] { + let msg = Setup { + token: Some(unsendable), + ..Default::default() + }; + assert!(matches!(msg.encode_bytes(Version::Lite07), Err(EncodeError::Unsupported))); + } + + // An empty value carries no credential, so it is refused rather than read as none. + assert!(Setup::decode_slice(&[0x03, 0x01, 0x06, 0x00], Version::Lite07).is_err()); + } + + /// Only the client presents a connection credential. + #[test] + fn a_server_token_is_a_protocol_violation() { + let setup = Setup { + token: Some(crate::setup::Token { + kind: crate::setup::Token::OUT_OF_BAND, + value: b"jwt".to_vec(), + }), + ..Default::default() + }; + let buf = setup.encode_bytes(Version::Lite07).unwrap(); + let received = |buf: &[u8]| ReceivedSetup::decode_slice(buf, Version::Lite07).unwrap().0; + + let client = PeerSetup::new(true, crate::transport::Binding::InBand); + assert!(matches!(client.receive(received(&buf)), Err(crate::Error::ProtocolViolation))); + let server = PeerSetup::new(false, crate::transport::Binding::InBand); + server.receive(received(&buf)).unwrap(); + } + /// Never emit a SETUP our own receiver would refuse. #[test] fn encode_enforces_the_setup_limit() { diff --git a/rs/moq-net/src/server.rs b/rs/moq-net/src/server.rs index 27f5c9fc705..e74af6b169b 100644 --- a/rs/moq-net/src/server.rs +++ b/rs/moq-net/src/server.rs @@ -168,6 +168,7 @@ impl Server { cost: None, // Filled by `lite::start` from the attached origin handles. hop: None, + token: None, ietf: self.extensions.ietf && version.has_ietf(), } } else { @@ -300,12 +301,15 @@ impl Server { } PausedHandshake::Boxed(_) => false, }); + let token = match &handshake { + PausedHandshake::LiteSetup { client_setup, .. } => client_setup.setup.token.clone(), + _ => None, + }; Ok(Handshake { path, role, origin, - // moq-lite carries no SETUP token. - token: None, + token, assigned_hop: crate::Hop::random(), auth, inner: Some(RequestInner { @@ -639,6 +643,7 @@ where version, path: None, authority: None, + token: None, peer_setup_stream: Some(peer_setup.stream), peer_declared: Some(peer_setup.declared), auth: auth.clone(), @@ -774,6 +779,7 @@ where version: v, path: None, authority: None, + token: None, peer_setup_stream: None, peer_declared: Some(peer_declared), auth: auth.clone(), @@ -852,10 +858,11 @@ where self.auth.clone() } - /// The credential the client presented in its SETUP's `AUTHORIZATION TOKEN` option. + /// The connection credential the client presented in its SETUP: moq-transport's + /// `AUTHORIZATION TOKEN` option, or moq-lite-07's Token parameter. /// - /// Only moq-transport carries one, so moq-lite sessions return `None`. The transport - /// has not verified it: authorize on it the way you would a URL token. + /// Older moq-lite versions carry none, so they return `None`. The transport has not + /// verified it: authorize on it the way you would a URL token. pub fn token(&self) -> Option<&setup::Token> { self.token.as_ref() } @@ -1186,6 +1193,7 @@ mod tests { role, cost: None, hop, + token: None, ietf: false, } .encode(&mut crate::coding::Encoder::new(&mut buf, v.into()), v) diff --git a/rs/moq-net/src/setup.rs b/rs/moq-net/src/setup.rs index 1e8eb5dcfd9..098f6132cd2 100644 --- a/rs/moq-net/src/setup.rs +++ b/rs/moq-net/src/setup.rs @@ -57,10 +57,11 @@ impl Default for Extensions { } } -/// A credential a moq-transport peer presented in its SETUP's `AUTHORIZATION TOKEN` option. +/// A connection credential presented in SETUP: moq-transport's `AUTHORIZATION TOKEN` +/// option, or moq-lite-07's Token parameter. /// /// The transport never reads the bytes; verifying them is the application's job. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Clone, PartialEq, Eq)] pub struct Token { /// The wire Token Type, naming how [`value`](Self::value) is encoded. pub kind: u64, @@ -75,6 +76,13 @@ impl Token { pub const CAT: u64 = 0x1; } +// SETUP and handshakes are logged with `Debug`, so the credential shows only its type and length. +impl std::fmt::Debug for Token { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Token(kind={}, <{} bytes>)", self.kind, self.value.len()) + } +} + /// Draft-17+ unified SETUP message, with the same encoding for both client and server. #[derive(Debug, Clone)] #[cfg_attr(not(feature = "async"), allow(dead_code))] diff --git a/rs/moq-net/tests/integration/auth.rs b/rs/moq-net/tests/integration/auth.rs index 8f915dfb52b..2fa45d55efb 100644 --- a/rs/moq-net/tests/integration/auth.rs +++ b/rs/moq-net/tests/integration/auth.rs @@ -62,6 +62,7 @@ cases!( a_revoked_grant_withdraws_and_can_be_restored, a_refused_token_reports_the_code, a_refused_setup_token_grants_nothing, + the_setup_token_is_the_connection_credential, dropping_the_requests_refuses_queued_tokens, a_closed_session_holds_no_grant, an_issued_grant_ends_with_its_session, @@ -402,6 +403,8 @@ struct Options { client_extensions: Option, server_extensions: Option, version: Option<&'static str>, + /// The connection credential the client's SETUP presents. + client_token: Option<&'static [u8]>, } struct Pair { @@ -410,6 +413,8 @@ struct Pair { client_transport: MockSession, server_transport: MockSession, requests: Option, + /// The SETUP token the server's handshake read. + setup_token: Option, /// Aborting it drops the server's driver without letting it finish. server_driver: moq_clock::sim::JoinHandle, } @@ -421,6 +426,12 @@ async fn connect(opts: Options) -> Pair { let (client_transport, server_transport) = create_mock_session_pair(mock); let mut client = Client::new().with_versions(version.into()); + if let Some(token) = opts.client_token { + client = client.with_token(moq_net::setup::Token { + kind: moq_net::setup::Token::OUT_OF_BAND, + value: token.to_vec(), + }); + } if let Some(extensions) = opts.client_extensions { client = client.with_extensions(extensions); } @@ -454,6 +465,7 @@ async fn connect(opts: Options) -> Pair { .accept_request(now(), server_transport) .await .expect("server handshake"); + let setup_token = handshake.token().cloned(); // Taken before the session starts, the way a relay verifying tokens would. let requests = opts .server_requests @@ -462,9 +474,9 @@ async fn connect(opts: Options) -> Pair { let driver = moq_clock::sim::spawn(moq_clock::drive(move |now, waiter| { moq_net::time::Driver::poll(&mut driver, now, waiter) })); - (session, requests, driver) + (session, requests, setup_token, driver) }; - let (client, (server, requests, server_driver)) = futures::join!(client_fut, server_fut); + let (client, (server, requests, setup_token, server_driver)) = futures::join!(client_fut, server_fut); Pair { client, @@ -472,6 +484,7 @@ async fn connect(opts: Options) -> Pair { client_transport: observe, server_transport: observe_server, requests, + setup_token, server_driver, } } @@ -862,6 +875,49 @@ async fn a_refused_setup_token_grants_nothing(version: &'static str) { .expect("timed out"); } +/// The token a client's SETUP presents reaches the server's handshake, and the client's +/// empty-token AUTH stream learns the grant the server gives it. +async fn the_setup_token_is_the_connection_credential(version: &'static str) { + within(async { + let mut pair = connect(Options { + version: Some(version), + client_publish: Some(produce_origin(2)), + server_requests: true, + client_token: Some(b"connection-credential"), + ..Default::default() + }) + .await; + assert_eq!( + pair.setup_token.map(|token| (token.kind, token.value)), + Some((moq_net::setup::Token::OUT_OF_BAND, b"connection-credential".to_vec())) + ); + + let _issued = serve(pair.requests.take().unwrap(), |token| { + token.is_empty().then(|| grant(&["room"], &[])) + }); + assert_eq!(granted(&pair.client).await, grant(&["room"], &[])); + }) + .await + .expect("timed out"); +} + +/// A lite version without the Token parameter carries none, so the caller's URL query +/// has to. +#[moq_clock::test] +async fn lite_06_sends_no_setup_token() { + within(async { + let pair = connect(Options { + version: Some(LITE_06), + client_token: Some(b"connection-credential"), + ..Default::default() + }) + .await; + assert_eq!(pair.setup_token, None); + }) + .await + .expect("timed out"); +} + /// Dropping the requests refuses tokens already queued, not only later ones. async fn dropping_the_requests_refuses_queued_tokens(version: &'static str) { within(async { From c62185b6a420332f7e595d2ee5620a1fbc818226 Mon Sep 17 00:00:00 2001 From: Melanie Riise Date: Tue, 20 Oct 2026 20:17:32 -0700 Subject: [PATCH 27/45] feat(tokio): configured tokens and credential refresh connect::Config gains tokens (--connect-token, MOQ_CONNECT_TOKEN). The first rides SETUP, and the URL query while an offered version has no SETUP token; the rest each ride an AUTH stream. A URL that already carries ?jwt= keeps it, and every token rides AUTH. Client::tokens() returns a connect::Tokens handle: each dial reads it, a refusal redials only when it changed, and a live session swaps its AUTH tokens, withdrawing the replaced ones once their replacements are answered. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/version.rs | 10 + rs/moq-tokio/src/client.rs | 55 +++- rs/moq-tokio/src/connect.rs | 29 +++ rs/moq-tokio/src/connect/token.rs | 305 +++++++++++++++++++++++ rs/moq-tokio/src/connection.rs | 44 +++- rs/moq-tokio/src/connection/auth.rs | 185 ++++++++++++++ rs/moq-tokio/src/settings.rs | 3 + rs/moq-tokio/tests/integration/main.rs | 1 + rs/moq-tokio/tests/integration/tokens.rs | 161 ++++++++++++ 9 files changed, 776 insertions(+), 17 deletions(-) create mode 100644 rs/moq-tokio/src/connect/token.rs create mode 100644 rs/moq-tokio/tests/integration/tokens.rs diff --git a/rs/moq-net/src/version.rs b/rs/moq-net/src/version.rs index 4dd475fd29f..04e58185c2e 100644 --- a/rs/moq-net/src/version.rs +++ b/rs/moq-net/src/version.rs @@ -93,6 +93,16 @@ impl Version { } } + /// Whether the client's SETUP can carry its connection credential (see + /// [`Client::with_token`](crate::Client::with_token)). Every moq-transport draft can; + /// moq-lite gained it in lite-07, so older versions need the credential in the URL. + pub fn has_setup_token(self) -> bool { + match self { + Self::Lite(version) => version.has_auth(), + Self::Ietf(_) => true, + } + } + /// Iterate the names accepted by [`FromStr`]. pub fn names() -> impl Iterator { ALL.iter().map(Self::as_str) diff --git a/rs/moq-tokio/src/client.rs b/rs/moq-tokio/src/client.rs index 446c9f91712..748912d208e 100644 --- a/rs/moq-tokio/src/client.rs +++ b/rs/moq-tokio/src/client.rs @@ -50,6 +50,8 @@ pub struct Client { versions: moq_net::Versions, /// The URL from [`connect.url`](crate::connect::Config::url), dialed by [`Client::publish`] / [`Client::consume`]. connect: Option, + /// The credentials every dial presents, shared with each clone. + tokens: crate::connect::Tokens, /// Deadline for one [`Client::connect`], from [`crate::connect::Config::timeout`]. Zero waits forever. #[cfg(feature = "_transport")] timeout: std::time::Duration, @@ -140,6 +142,7 @@ impl Client { .with_extensions(config.extensions), versions, connect: config.url, + tokens: crate::connect::Tokens::new(config.tokens), timeout, reconnect: !config.once.unwrap_or(false), backoff: config.backoff, @@ -230,6 +233,13 @@ impl Client { self } + /// The credentials every dial presents, seeded from + /// [`connect.tokens`](crate::connect::Config::tokens). Set new ones before the old + /// ones expire. + pub fn tokens(&self) -> crate::connect::Tokens { + self.tokens.clone() + } + /// Override whether this client redials after a session drop. /// /// Defaults to true, unless [`crate::connect::Config::once`] turned it off. @@ -341,7 +351,11 @@ impl Client { feature = "tcp", feature = "uds" )))] - pub(crate) async fn dial(&self, _addr: crate::connect::Addr) -> crate::Result { + pub(crate) async fn dial( + &self, + _addr: crate::connect::Addr, + _tokens: &crate::connect::Set, + ) -> crate::Result<(Dialed, crate::connect::Credential)> { Err(Error::NoBackend( "no backend compiled; enable quic, iroh, websocket, tcp, or uds feature", )) @@ -360,10 +374,28 @@ impl Client { feature = "tcp", feature = "uds" ))] - pub(crate) async fn dial(&self, addr: crate::connect::Addr) -> crate::Result { + pub(crate) async fn dial( + &self, + addr: crate::connect::Addr, + tokens: &crate::connect::Set, + ) -> crate::Result<(Dialed, crate::connect::Credential)> { + let credential = crate::connect::Credential::new(addr.url(), &self.versions, tokens); + let addr = addr.with_url(credential.url.clone()); + let client = match &credential.setup { + Some(token) => { + let mut client = self.clone(); + client.moq = client.moq.with_token(moq_net::setup::Token { + kind: moq_net::setup::Token::OUT_OF_BAND, + value: token.as_str().as_bytes().to_vec(), + }); + std::borrow::Cow::Owned(client) + } + None => std::borrow::Cow::Borrowed(self), + }; + // Each compiled backend adds state to this dispatch future. Keep it off the // caller's stack so all-feature builds remain safe on standard 2 MiB threads. - let attempt = Box::pin(self.connect_inner(addr)); + let attempt = Box::pin(client.connect_inner(addr)); // The deadline covers the dial AND the handshake, for every transport: it is the // only bound some of them have. Dropping `attempt` on expiry cancels whichever @@ -377,7 +409,7 @@ impl Client { }; tracing::info!(version = %dialed.session.version(), transport = %dialed.transport, "connected"); - Ok(dialed) + Ok((dialed, credential)) } /// The moq client builder, advertising `path` in the SETUP when there is one. @@ -1134,6 +1166,18 @@ mod tests { assert_eq!(config.resolve().race, std::time::Duration::from_millis(50)); } + /// Tokens repeat, keep their order, and are refused when empty. + #[test] + fn test_connect_tokens() { + let config = Cli::config_from(["test", "--connect-token", "first", "--connect-token", "second"]); + let tokens: Vec<&str> = config.tokens.iter().map(crate::connect::Token::as_str).collect(); + assert_eq!(tokens, ["first", "second"]); + + let config: crate::connect::Config = toml::from_str(r#"tokens = ["first"]"#).unwrap(); + assert_eq!(config.tokens.len(), 1); + assert!(toml::from_str::(r#"tokens = [""]"#).is_err()); + } + #[test] fn test_toml_resolution_delay_survives_update_from() { let toml = r#" @@ -1773,7 +1817,8 @@ mod tests { // `dial` rather than `connect`: this is about one attempt's deadline, and // `connect` now hands back a reconnect loop that would redial past it. - let mut attempt = Box::pin(client.dial(url.into())); + let tokens = Default::default(); + let mut attempt = Box::pin(client.dial(url.into(), &tokens)); let _silent = tokio::select! { res = &mut attempt => match res { Err(err) => panic!("connect failed before the silent peer accepted it: {err}"), diff --git a/rs/moq-tokio/src/connect.rs b/rs/moq-tokio/src/connect.rs index e1767221ab8..297ae1fa795 100644 --- a/rs/moq-tokio/src/connect.rs +++ b/rs/moq-tokio/src/connect.rs @@ -8,6 +8,10 @@ use crate::connection::Goaway; use std::net; use url::Url; +mod token; +pub(crate) use token::{Credential, Set}; +pub use token::{EmptyToken, Token, Tokens}; + /// Maximum time for one dial, unless overridden by `--connect-timeout`. pub(crate) const DEFAULT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); @@ -85,6 +89,14 @@ impl Addr { pub fn addresses(&self) -> Option<&[net::SocketAddr]> { self.resolved.as_deref() } + + /// The same peer and addresses, requested at `url`. + pub(crate) fn with_url(&self, url: Url) -> Self { + Self { + url, + resolved: self.resolved.clone(), + } + } } impl From for Addr { @@ -504,6 +516,22 @@ pub struct Config { #[usage(name = "connect", long = "connect", env = "MOQ_CONNECT", setting = "connect.url")] pub url: Option, + /// Credentials to present on every dial, kept apart from the URL. + /// + /// The first is the connection credential: it rides SETUP, and the URL query too + /// while an offered version can't carry it in SETUP (lite before 07). Each other + /// token rides its own AUTH stream, widening the session's grant. A URL that + /// already has a `?jwt=` keeps it as the connection credential, and then every + /// token rides AUTH. Replace them while running with [`crate::Client::tokens`]. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + #[usage( + name = "connect-token", + long = "connect-token", + env = "MOQ_CONNECT_TOKEN", + setting = "connect.tokens" + )] + pub tokens: Vec, + /// The released `connect` key, kept so [`deprecated`](Self::deprecated) can name [`url`](Self::url). #[serde(default, skip_serializing)] #[usage(skip)] @@ -714,6 +742,7 @@ impl Default for Config { fn default() -> Self { Self { url: None, + tokens: Vec::new(), connect: None, bind: None, race: Some(DEFAULT_RACE), diff --git a/rs/moq-tokio/src/connect/token.rs b/rs/moq-tokio/src/connect/token.rs new file mode 100644 index 00000000000..c001db8f626 --- /dev/null +++ b/rs/moq-tokio/src/connect/token.rs @@ -0,0 +1,305 @@ +//! The credentials a client presents, kept out of logs and replaceable while it runs. + +use moq_net::kio; +use url::Url; + +/// A credential to present when dialing, such as a JWT. +/// +/// Its `Debug` shows only the length, so a dumped config never leaks it. +#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(try_from = "String", into = "String")] +pub struct Token(String); + +impl Token { + /// The credential itself. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Debug for Token { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Token(<{} bytes>)", self.0.len()) + } +} + +impl std::str::FromStr for Token { + type Err = EmptyToken; + + fn from_str(token: &str) -> Result { + Self::try_from(token.to_string()) + } +} + +impl TryFrom for Token { + type Error = EmptyToken; + + fn try_from(token: String) -> Result { + match token.is_empty() { + true => Err(EmptyToken), + false => Ok(Self(token)), + } + } +} + +impl From for String { + fn from(token: Token) -> Self { + token.0 + } +} + +/// An empty token, which presents no credential. +#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)] +#[error("a token cannot be empty")] +#[non_exhaustive] +pub struct EmptyToken; + +/// The tokens a client presents, which every dial reads afresh. +/// +/// Clones share one set, as do the [`crate::Client`] clones it came from. Replacing it +/// reaches the next dial and, on a live session with AUTH, presents the new tokens at +/// once, withdrawing the ones they replace only after every new one has its answer. +#[derive(Clone)] +pub struct Tokens(kio::Producer); + +/// One version of the tokens, numbered so a dial can tell whether they changed since. +#[derive(Clone, Default)] +pub(crate) struct Set { + pub tokens: Vec, + pub generation: u64, +} + +impl Tokens { + pub(crate) fn new(tokens: Vec) -> Self { + Self(kio::Producer::new(Set { tokens, generation: 0 })) + } + + /// Replace every token. The first is the connection credential, unless the dial URL + /// carries its own `?jwt=`. + pub fn set(&self, tokens: Vec) { + // Never closed: this handle is a producer. + let Ok(mut set) = self.0.write() else { return }; + if set.tokens != tokens { + set.tokens = tokens; + set.generation += 1; + } + } + + pub(crate) fn snapshot(&self) -> Set { + self.0.read().clone() + } + + pub(crate) fn consume(&self) -> kio::Consumer { + self.0.consume() + } +} + +/// What one dial presents, decided from its URL, the offered versions, and the tokens. +pub(crate) struct Credential { + /// The URL to dial, carrying the connection credential in its query when an offered + /// version has no SETUP token. + pub url: Url, + /// The token SETUP carries, unless the URL brought its own credential. + pub setup: Option, + /// The credential the connection presents, from the URL or SETUP. Never also + /// presented on an AUTH stream. + connection: Option, +} + +impl Credential { + pub fn new(url: &Url, versions: &moq_net::Versions, set: &Set) -> Self { + if let Some(jwt) = jwt(url) { + // The URL's own credential (a cluster's per-peer `?jwt=`) wins, so the tokens + // all ride AUTH and no second credential reaches SETUP or the query. + return Self { + url: url.clone(), + setup: None, + connection: Some(jwt), + }; + } + let Some(first) = set.tokens.first() else { + return Self { + url: url.clone(), + setup: None, + connection: None, + }; + }; + // The URL is fixed before the version is negotiated, so it carries the credential + // whenever a version that may be picked could not carry it in SETUP. + let url = match versions.iter().all(|version| version.has_setup_token()) { + true => url.clone(), + false => with_jwt(url, first.as_str()), + }; + Self { + url, + setup: Some(first.clone()), + connection: Some(first.as_str().to_string()), + } + } + + /// The tokens to hold on their own AUTH streams, each once. + pub fn extra(&self, tokens: &[Token]) -> Vec { + let mut extra: Vec = Vec::new(); + for token in tokens { + if self.connection.as_deref() != Some(token.as_str()) && !extra.contains(token) { + extra.push(token.clone()); + } + } + extra + } +} + +/// The request target's query: the URL's own, or for `unix:` the one inside `?path=`. +fn target_query(url: &Url) -> Option { + match url.scheme() { + "unix" => url + .query_pairs() + .find(|(k, _)| k == "path") + .and_then(|(_, path)| path.split_once('?').map(|(_, query)| query.to_string())), + _ => url.query().map(str::to_string), + } +} + +/// The non-empty `jwt` the URL's request target already carries. +fn jwt(url: &Url) -> Option { + let query = target_query(url)?; + url::form_urlencoded::parse(query.as_bytes()) + .find(|(k, v)| k == "jwt" && !v.is_empty()) + .map(|(_, v)| v.into_owned()) +} + +/// `url` with `jwt` appended to its request target's query. +fn with_jwt(url: &Url, jwt: &str) -> Url { + let mut url = url.clone(); + if url.scheme() != "unix" { + url.query_pairs_mut().append_pair("jwt", jwt); + return url; + } + // A unix socket's request target is one form-encoded `?path=` value, so the + // credential joins the query inside it. + let pairs: Vec<(String, String)> = url.query_pairs().into_owned().collect(); + let mut serializer = url::form_urlencoded::Serializer::new(String::new()); + let mut found = false; + for (k, v) in &pairs { + match k == "path" { + true => { + found = true; + let mut target = url::form_urlencoded::Serializer::new(String::new()); + target.append_pair("jwt", jwt); + let separator = if v.contains('?') { '&' } else { '?' }; + serializer.append_pair(k, &format!("{v}{separator}{}", target.finish())); + } + false => { + serializer.append_pair(k, v); + } + } + } + if !found { + let mut target = url::form_urlencoded::Serializer::new(String::new()); + target.append_pair("jwt", jwt); + serializer.append_pair("path", &format!("?{}", target.finish())); + } + url.set_query(Some(&serializer.finish())); + url +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tokens(tokens: &[&str]) -> Set { + Set { + tokens: tokens.iter().map(|token| token.parse().unwrap()).collect(), + generation: 3, + } + } + + fn versions(names: &[&str]) -> moq_net::Versions { + names + .iter() + .map(|name| name.parse::().unwrap()) + .collect::>() + .into() + } + + /// Only lite-07 and moq-transport carry the credential in SETUP, so the URL stays clean. + #[test] + fn setup_versions_keep_the_credential_out_of_the_url() { + let url: Url = "https://relay.example.com/room".parse().unwrap(); + let credential = Credential::new( + &url, + &versions(&["moq-lite-07-wip", "moq-transport-17"]), + &tokens(&["a", "b"]), + ); + assert_eq!(credential.url, url); + assert_eq!(credential.setup.as_ref().map(Token::as_str), Some("a")); + assert_eq!(credential.extra(&tokens(&["a", "b"]).tokens), tokens(&["b"]).tokens); + } + + /// An offered version without a SETUP token needs the credential in the query, and the + /// same value rides SETUP too. + #[test] + fn an_older_version_puts_the_credential_in_the_url() { + let url: Url = "https://relay.example.com/room?x=1".parse().unwrap(); + let credential = Credential::new(&url, &moq_net::Versions::all(), &tokens(&["a b"])); + assert_eq!(credential.url.as_str(), "https://relay.example.com/room?x=1&jwt=a+b"); + assert_eq!(credential.setup.as_ref().map(Token::as_str), Some("a b")); + assert!(credential.extra(&tokens(&["a b"]).tokens).is_empty()); + } + + /// A URL credential stays the connection credential, so every token rides AUTH, + /// except one equal to it, which would be granted twice. + #[test] + fn a_url_credential_sends_every_token_on_auth() { + let url: Url = "moqt://relay.example.com/room?jwt=peer".parse().unwrap(); + let credential = Credential::new(&url, &moq_net::Versions::all(), &tokens(&["a", "peer", "a"])); + assert_eq!(credential.url, url); + assert_eq!(credential.setup, None); + assert_eq!(credential.extra(&tokens(&["a", "peer", "a"]).tokens), tokens(&["a"]).tokens); + } + + #[test] + fn no_tokens_present_nothing() { + let url: Url = "https://relay.example.com/room".parse().unwrap(); + let credential = Credential::new(&url, &moq_net::Versions::all(), &Set::default()); + assert_eq!(credential.url, url); + assert_eq!(credential.setup, None); + } + + /// A unix socket's request target lives in `?path=`, so the credential goes inside it. + #[test] + fn a_unix_credential_joins_the_path_target() { + for (url, expected) in [ + ("unix:///tmp/moq.sock?path=%2Froom", "/room?jwt=a"), + ("unix:///tmp/moq.sock?path=%2Froom%3Fx%3D1", "/room?x=1&jwt=a"), + ("unix:///tmp/moq.sock", "?jwt=a"), + ] { + let url: Url = url.parse().unwrap(); + let credential = Credential::new(&url, &moq_net::Versions::all(), &tokens(&["a"])); + let path = credential + .url + .query_pairs() + .find(|(k, _)| k == "path") + .map(|(_, v)| v.into_owned()); + assert_eq!(path.as_deref(), Some(expected), "{url}"); + // The rewritten target is read back as already carrying the credential. + assert_eq!(jwt(&credential.url).as_deref(), Some("a")); + } + } + + #[test] + fn a_token_never_logs_its_value() { + let token: Token = "secret".parse().unwrap(); + assert_eq!(format!("{token:?}"), "Token(<6 bytes>)"); + assert!("".parse::().is_err()); + } + + #[test] + fn set_bumps_the_generation_only_on_change() { + let tokens = Tokens::new(vec!["a".parse().unwrap()]); + tokens.set(vec!["a".parse().unwrap()]); + assert_eq!(tokens.snapshot().generation, 0); + tokens.set(vec!["b".parse().unwrap()]); + assert_eq!(tokens.snapshot().generation, 1); + } +} diff --git a/rs/moq-tokio/src/connection.rs b/rs/moq-tokio/src/connection.rs index 66b3ecd4dcb..bdddd4cd1c9 100644 --- a/rs/moq-tokio/src/connection.rs +++ b/rs/moq-tokio/src/connection.rs @@ -37,6 +37,15 @@ fn attempt_timeout(index: usize, total: usize) -> Option { (index + 1 < total).then_some(CONNECT_ATTEMPT) } +/// Whether `err` refused the credential a dial presented while the client has since been +/// given new tokens, which earns another dial instead of ending the loop. It never +/// retries a rejected credential: a refusal of unchanged tokens stays terminal. +fn refreshed(err: &Error, client: &Client, generation: u64) -> bool { + let refused = err.is_auth() + || matches!(err, Error::MoqNet(err) if moq_net::SessionError::from(err) == moq_net::SessionError::Unauthorized); + refused && client.tokens().snapshot().generation != generation +} + /// Whether a failed candidate is the peer's answer for every candidate, ending the walk. /// /// An auth rejection arrives as [`Error::Connect`] with its status already consumed, so it @@ -850,16 +859,22 @@ impl Connection { let mut addrs = addrs; // An old session kept alive after a GOAWAY so its in-flight groups finish. let mut draining: Option = None; + // Each session's configured tokens, held until that session ends or this loop does. + let mut held = Vec::new(); loop { if !timeout.is_zero() && retry.start.elapsed() >= timeout { return Err(timeout_error(timeout, retry.last_error.as_ref())); } - match Self::dial_any(shared, &client, &addrs, &mut draining).await { - Ok((addr, dialed)) => { + // Read once per dial, so a refusal is judged against what this dial presented. + let tokens = client.tokens().snapshot(); + match Self::dial_any(shared, &client, &addrs, &mut draining, &tokens).await { + Ok((addr, dialed, credential)) => { let url = addr.url().clone(); tracing::info!(peer = %Endpoint(&url), transport = %dialed.transport, "connected"); + held.retain(|task: &crate::abort::AbortOnDrop| !task.is_finished()); + held.push(auth::hold(&dialed.session, credential, client.tokens().consume())); shared.connected(&dialed.session, dialed.transport); let session = dialed.session; @@ -871,9 +886,13 @@ impl Connection { // Before any status moves, so a watcher woken by `Disconnected` finds why. Only // a retry has a reason to report; a session that ends the loop reports through // `closed` instead. + let refreshed = match &ended { + Ended::Closed(Err(err)) => refreshed(err, &client, tokens.generation), + _ => false, + }; if let Ended::Closed(Err(err)) = &ended && client.reconnect - && !err.is_terminal() + && (!err.is_terminal() || refreshed) { shared.retrying(err); } @@ -979,7 +998,7 @@ impl Connection { // the session lived. A new connection cannot repair either one. if let Ended::Closed(Err(err)) = &ended { let err = err.clone(); - if err.is_terminal() { + if err.is_terminal() && !refreshed { return Err(err); } } @@ -1027,7 +1046,7 @@ impl Connection { } } Err(err) => { - if err.is_terminal() || !client.reconnect { + if (err.is_terminal() && !refreshed(&err, &client, tokens.generation)) || !client.reconnect { return Err(err); } shared.retrying(&err); @@ -1067,7 +1086,8 @@ impl Connection { client: &Client, addrs: &Addrs, draining: &mut Option, - ) -> crate::Result<(crate::connect::Addr, crate::client::Dialed)> { + tokens: &crate::connect::Set, + ) -> crate::Result<(crate::connect::Addr, crate::client::Dialed, crate::connect::Credential)> { let candidates = addrs.as_slice(); let mut last = None; let mut transient = None; @@ -1076,7 +1096,7 @@ impl Connection { let url = addr.url(); tracing::info!(peer = %Endpoint(url), "connecting"); - let mut dial = std::pin::pin!(client.dial(addr.clone())); + let mut dial = std::pin::pin!(client.dial(addr.clone(), tokens)); let dialed = kio::wait(|waiter| { if poll_draining(draining, waiter) { shared.disconnected(); @@ -1093,7 +1113,7 @@ impl Connection { }; match dialed { - Ok(dialed) => return Ok((addr.clone(), dialed)), + Ok((dialed, credential)) => return Ok((addr.clone(), dialed, credential)), // A status the peer actually sent is its answer, not this address's, so // unless it invites another attempt it settles the whole walk. Carrying // on would offer the same rejected credentials at the peer's other @@ -2167,9 +2187,9 @@ mod tests { let shared = shared(); let mut draining = None; - let (connected, _session) = tokio::time::timeout( + let (connected, _session, _) = tokio::time::timeout( WALK_TIMEOUT, - Connection::dial_any(&shared, &client, &addrs, &mut draining), + Connection::dial_any(&shared, &client, &addrs, &mut draining, &Default::default()), ) .await .expect("the walk must not hang") @@ -2191,7 +2211,7 @@ mod tests { let result = tokio::time::timeout( WALK_TIMEOUT, - Connection::dial_any(&shared, &client, &addrs, &mut draining), + Connection::dial_any(&shared, &client, &addrs, &mut draining, &Default::default()), ) .await .expect("the walk must not hang"); @@ -2219,7 +2239,7 @@ mod tests { let shared = shared(); let mut draining = None; // `Session` isn't `Debug`, so unwrap the failure by hand. - let Err(err) = Connection::dial_any(&shared, &client, &addrs, &mut draining).await else { + let Err(err) = Connection::dial_any(&shared, &client, &addrs, &mut draining, &Default::default()).await else { panic!("a dead address must fail"); }; diff --git a/rs/moq-tokio/src/connection/auth.rs b/rs/moq-tokio/src/connection/auth.rs index ea686213d3a..723e90fe246 100644 --- a/rs/moq-tokio/src/connection/auth.rs +++ b/rs/moq-tokio/src/connection/auth.rs @@ -400,6 +400,133 @@ async fn present( } } +/// Present a dial's configured tokens on its session, other than the connection +/// credential, and keep them in step with the client's [`crate::connect::Tokens`] until +/// the session ends. +/// +/// The first tokens register before this returns, so call it before the session is +/// published: a request the session sends early waits on their answers rather than +/// being refused outside the connection credential's grant. +pub(super) fn hold( + session: &moq_net::Session, + credential: crate::connect::Credential, + tokens: kio::Consumer, +) -> AbortOnDrop { + let set = (*tokens.read()).clone(); + let mut held = Held::default(); + held.update(&session.auth(), credential.extra(&set.tokens)); + let session = session.clone(); + AbortOnDrop::new(tokio::spawn(async move { + let mut generation = set.generation; + loop { + let changed = { + let changed = async { + let wait = tokens.wait(|set| match set.generation != generation { + true => Poll::Ready((set.tokens.clone(), set.generation)), + false => Poll::Pending, + }); + // Every client handle dropped, so the tokens can no longer change. + match wait.await { + Ok(changed) => changed, + Err(_) => std::future::pending().await, + } + }; + let answered = std::future::poll_fn(|cx| held.poll_answers(&session, cx)); + tokio::select! { + biased; + _ = session.closed() => return, + changed = changed => Some(changed), + _ = answered => None, + } + }; + if let Some((latest, next)) = changed { + generation = next; + held.update(&session.auth(), credential.extra(&latest)); + } + } + })) +} + +/// The configured tokens one session holds on their own AUTH streams. +#[derive(Default)] +struct Held { + tokens: Vec<(crate::connect::Token, Answer)>, + /// Tokens a change replaced, answered or not, kept presented until every + /// replacement has its answer so the grant never narrows in between. + retiring: Vec, +} + +enum Answer { + Waiting(Opening), + Granted(#[allow(dead_code, reason = "held to keep the token presented")] auth::Auth), + /// Refused or unsupported. Not presented again unless a change removes and re-adds it. + Refused, +} + +impl Held { + fn update(&mut self, handle: &auth::Handle, tokens: Vec) { + let mut old = std::mem::take(&mut self.tokens); + for token in tokens { + let answer = match old.iter().position(|(held, _)| *held == token) { + Some(index) => old.swap_remove(index).1, + None => { + let handle = handle.clone(); + let bytes = Bytes::from(token.as_str().to_string()); + let mut opening = async move { handle.add(bytes).await }.boxed(); + // The first poll registers the token synchronously, as `Replay::queue` does. + if let Some(result) = opening.as_mut().now_or_never() { + opening = futures::future::ready(result).boxed(); + } + Answer::Waiting(opening) + } + }; + self.tokens.push((token, answer)); + } + // A waiting registration may already hold its grant, unread, so it retires too. + self.retiring.extend( + old.into_iter() + .map(|(_, answer)| answer) + .filter(|answer| !matches!(answer, Answer::Refused)), + ); + self.settle(); + } + + /// Resolve when another token has its answer. + fn poll_answers(&mut self, session: &moq_net::Session, cx: &mut std::task::Context<'_>) -> Poll<()> { + let mut answered = false; + for (_, answer) in &mut self.tokens { + let Answer::Waiting(opening) = answer else { continue }; + let Poll::Ready(result) = opening.as_mut().poll(cx) else { continue }; + answered = true; + *answer = match result { + Ok(auth) => Answer::Granted(auth), + // The session ending is no verdict on the token. + Err(_) if session.closed().now_or_never().is_some() => Answer::Refused, + Err(moq_net::Error::Unsupported) => { + tracing::debug!("the peer takes no tokens in band; presenting only the connection credential"); + Answer::Refused + } + Err(err) => { + tracing::warn!(%err, "a configured token was refused"); + Answer::Refused + } + }; + } + self.settle(); + match answered { + true => Poll::Ready(()), + false => Poll::Pending, + } + } + + /// Withdraw the replaced tokens once nothing is waiting on an answer. + fn settle(&mut self) { + if !self.tokens.iter().any(|(_, answer)| matches!(answer, Answer::Waiting(_))) { + self.retiring.clear(); + } + } +} + #[cfg(all(test, feature = "tcp"))] mod tests { use super::*; @@ -730,4 +857,62 @@ mod tests { assert!(token.grant().peek().is_none()); issued.closed().await; } + + /// The configured tokens on a lite-07 dial, whose SETUP carries the first. + fn configured(tokens: &[&str]) -> (crate::connect::Tokens, crate::connect::Credential) { + let tokens = crate::connect::Tokens::new(tokens.iter().map(|token| token.parse().unwrap()).collect()); + let url: url::Url = "moqt://relay.example.com/".parse().unwrap(); + let versions = moq_net::Versions::from("moq-lite-07-wip".parse::().unwrap()); + let credential = crate::connect::Credential::new(&url, &versions, &tokens.snapshot()); + (tokens, credential) + } + + async fn next_token(pair: &mut Pair) -> auth::Request { + tokio::time::timeout(std::time::Duration::from_secs(5), pair.requests.recv()) + .await + .expect("no token presented") + .unwrap() + } + + /// Every configured token but the connection credential rides its own AUTH stream. + #[tokio::test(start_paused = true)] + async fn configured_tokens_ride_auth_beside_the_connection_credential() { + let mut pair = pair().await; + let (tokens, credential) = configured(&["connection", "a", "b"]); + let _held = hold(&pair.client, credential, tokens.consume()); + let mut presented = Vec::new(); + let mut issued = Vec::new(); + for _ in 0..2 { + let request = next_token(&mut pair).await; + presented.push(request.token().to_vec()); + issued.push(request.accept(auth::Grant::all())); + } + presented.sort(); + assert_eq!(presented, [b"a".to_vec(), b"b".to_vec()]); + let again = tokio::time::timeout(std::time::Duration::from_secs(1), pair.requests.recv()).await; + assert!(again.is_err(), "the connection credential was presented twice"); + } + + /// A replaced token is withdrawn only once its replacement has an answer, so the grant + /// never narrows in between. + #[tokio::test(start_paused = true)] + async fn a_replaced_token_is_withdrawn_after_its_replacement_is_answered() { + let mut pair = pair().await; + let (tokens, credential) = configured(&["connection", "old"]); + let _held = hold(&pair.client, credential, tokens.consume()); + let old = next_token(&mut pair).await.accept(auth::Grant::all()); + + tokens.set(vec!["connection".parse().unwrap(), "new".parse().unwrap()]); + let request = next_token(&mut pair).await; + assert_eq!(request.token().as_ref(), b"new"); + let early = tokio::time::timeout(std::time::Duration::from_secs(1), old.closed()).await; + assert!(early.is_err(), "withdrawn before its replacement was answered"); + + let new = request.accept(auth::Grant::all()); + tokio::time::timeout(std::time::Duration::from_secs(5), old.closed()) + .await + .expect("the replaced token was never withdrawn"); + let kept = tokio::time::timeout(std::time::Duration::from_secs(1), new.closed()).await; + assert!(kept.is_err(), "the replacement was withdrawn"); + } } diff --git a/rs/moq-tokio/src/settings.rs b/rs/moq-tokio/src/settings.rs index 017b6c0b1b9..5f8e0a38955 100644 --- a/rs/moq-tokio/src/settings.rs +++ b/rs/moq-tokio/src/settings.rs @@ -135,6 +135,9 @@ pub struct Connect { #[usage(env = "MOQ_CONNECT", cli("--connect"))] url: Option, + #[usage(env = "MOQ_CONNECT_TOKEN", cli("--connect-token"), parse = "list_by_comma")] + tokens: Option>, + #[usage(env = "MOQ_CONNECT_BIND", cli("--connect-bind"))] bind: Option, diff --git a/rs/moq-tokio/tests/integration/main.rs b/rs/moq-tokio/tests/integration/main.rs index e95dab914d1..7ce5defbd54 100644 --- a/rs/moq-tokio/tests/integration/main.rs +++ b/rs/moq-tokio/tests/integration/main.rs @@ -13,6 +13,7 @@ mod reconnect; mod reset_secret; mod stream_code; mod subscription_end_integrity; +mod tokens; mod unannounce; mod worker; diff --git a/rs/moq-tokio/tests/integration/tokens.rs b/rs/moq-tokio/tests/integration/tokens.rs new file mode 100644 index 00000000000..c04504adedb --- /dev/null +++ b/rs/moq-tokio/tests/integration/tokens.rs @@ -0,0 +1,161 @@ +//! Configured tokens reach the server as the connection credential: in SETUP where the +//! negotiated version carries one, and in the URL query where it might not. A refused +//! credential is dialed again only once the client has a new one. + +#![cfg(feature = "quic")] + +use std::{sync::Arc, time::Duration}; + +use moq_tokio::moq_net; + +/// What the server saw on one handshake. +#[derive(Debug, PartialEq)] +struct Seen { + query: Option, + token: Option, +} + +struct Server { + port: u16, + seen: tokio::sync::mpsc::UnboundedReceiver, + /// Releases a held `refused` handshake to be rejected. + release: Arc, +} + +/// Serve `version`, rejecting as unauthorized any handshake presenting `refused`, once +/// [`Server::release`] lets it go. +async fn serve(version: &str, refused: &'static str) -> Server { + let mut config = moq_tokio::server::Config::default(); + config.listen.bind = Some("127.0.0.1:0".parse().unwrap()); + config.listen.tls.generate = vec!["localhost".into()]; + config.listen.version = vec![version.parse().unwrap()]; + let mut listener = config + .init() + .expect("failed to init server") + .listen() + .await + .expect("failed to listen"); + let port = listener.local_addr().expect("no quic addr").port(); + let (sender, seen) = tokio::sync::mpsc::unbounded_channel(); + let release = Arc::new(tokio::sync::Notify::new()); + + let notify = release.clone(); + tokio::spawn(async move { + while let Some(request) = listener.accept().await { + let seen = Seen { + query: request.query().map(str::to_string), + token: request + .token() + .map(|token| String::from_utf8(token.value.clone()).unwrap()), + }; + let rejected = seen.token.as_deref() == Some(refused) + || seen.query.as_deref().is_some_and(|query| query.contains(&format!("jwt={refused}"))); + sender.send(seen).ok(); + let notify = notify.clone(); + tokio::spawn(async move { + if rejected { + notify.notified().await; + request.reject(moq_tokio::server::Reject::Unauthorized).await.ok(); + return; + } + let Ok((_session, _driver)) = request.ok().await else { return }; + // Hold the session until the test's runtime shuts down. + std::future::pending::<()>().await; + }); + } + }); + + Server { port, seen, release } +} + +fn client(version: &str, tokens: &[&str]) -> moq_tokio::Client { + let mut config = moq_tokio::connect::Config::default(); + config.tls.insecure = Some(true); + config.version = vec![version.parse().unwrap()]; + config.tokens = tokens.iter().map(|token| token.parse().unwrap()).collect(); + config.init(Default::default()).expect("failed to init client") +} + +async fn next(server: &mut Server) -> Seen { + tokio::time::timeout(Duration::from_secs(10), server.seen.recv()) + .await + .expect("no handshake arrived") + .unwrap() +} + +/// lite-07 carries the credential in SETUP, so the URL stays as the caller wrote it. +#[tokio::test] +async fn lite_07_presents_the_token_in_setup() { + let mut server = serve("moq-lite-07-wip", "refused").await; + let client = client("moq-lite-07-wip", &["credential"]); + let url: url::Url = format!("moqt://localhost:{}/room", server.port).parse().unwrap(); + let _connection = client.connect(url); + assert_eq!( + next(&mut server).await, + Seen { + query: None, + token: Some("credential".into()), + } + ); +} + +/// lite-05 has no SETUP token, so the credential rides the request target's query. +#[tokio::test] +async fn lite_05_presents_the_token_in_the_url() { + let mut server = serve("moq-lite-05", "refused").await; + let client = client("moq-lite-05", &["credential"]); + let url: url::Url = format!("moqt://localhost:{}/room", server.port).parse().unwrap(); + let _connection = client.connect(url); + assert_eq!( + next(&mut server).await, + Seen { + query: Some("jwt=credential".into()), + token: None, + } + ); +} + +/// A refused credential the client has since replaced is dialed again with the new one. +#[tokio::test] +async fn a_refused_credential_redials_with_its_replacement() { + let mut server = serve("moq-lite-07-wip", "old").await; + let client = client("moq-lite-07-wip", &["old"]); + let url: url::Url = format!("moqt://localhost:{}/", server.port).parse().unwrap(); + let connection = client.connect(url); + assert_eq!(next(&mut server).await.token.as_deref(), Some("old")); + + client.tokens().set(vec!["new".parse().unwrap()]); + server.release.notify_one(); + assert_eq!(next(&mut server).await.token.as_deref(), Some("new")); + let _connection = tokio::time::timeout(Duration::from_secs(10), connection.established()) + .await + .expect("the replacement never connected") + .expect("the replacement was refused"); +} + +/// Without a new credential, a refusal ends the connection rather than retrying it. +#[tokio::test] +async fn an_unchanged_refused_credential_is_terminal() { + let mut server = serve("moq-lite-07-wip", "old").await; + let client = client("moq-lite-07-wip", &["old"]); + let url: url::Url = format!("moqt://localhost:{}/", server.port).parse().unwrap(); + let connection = client.connect(url); + assert_eq!(next(&mut server).await.token.as_deref(), Some("old")); + server.release.notify_one(); + // A lite client is live before the server's answer, so the refusal may close it after. + let err = tokio::time::timeout(Duration::from_secs(10), async { + match connection.established().await { + Ok(connection) => connection.closed().await.expect_err("closed without an error"), + Err(err) => err, + } + }) + .await + .expect("the refusal never ended the connection"); + assert!( + matches!( + err, + moq_tokio::Error::MoqNet(moq_net::Error::Session(moq_net::SessionError::Unauthorized)) + ), + "{err:?}" + ); +} From 0e80c6bab03aeff3a3c4e688e2d072c793cd1ccd Mon Sep 17 00:00:00 2001 From: Melanie Riise Date: Tue, 20 Oct 2026 20:26:30 -0700 Subject: [PATCH 28/45] feat(ffi): configured tokens; spec, docs, and interop for SETUP tokens MoqClientConfig gains tokens and MoqClient gains set_tokens, mirroring moq_tokio's connect::Config::tokens and Client::tokens(). The lite draft specs the client-only Token setup parameter (0x6). A relay test checks a configured token reaches the auth server as the SETUP token on lite-07 and as ?jwt= on lite-06, and the interop harness's Rust client now presents its token with --connect-token. Finishes the token-in-band quest. Co-Authored-By: Claude Opus 5.5 --- doc/bin/cli.md | 9 +- doc/bin/relay/auth.md | 20 ++++- doc/concept/transport.md | 4 +- doc/lib/rs/index.md | 8 +- drafts/draft-lcurley-moq-lite.md | 16 +++- quest/m1/auth/README.md | 3 - quest/m1/auth/token-in-band.md | 89 ------------------- rs/moq-ffi/src/session.rs | 62 +++++++++++++ .../tests/integration/auth_lifetime.rs | 61 +++++++++++++ test/interop/interop.sh | 19 +++- 10 files changed, 185 insertions(+), 106 deletions(-) delete mode 100644 quest/m1/auth/token-in-band.md diff --git a/doc/bin/cli.md b/doc/bin/cli.md index ae5e3526323..a7fff1e789d 100644 --- a/doc/bin/cli.md +++ b/doc/bin/cli.md @@ -43,8 +43,11 @@ moq fetch [options] ``` The **MoQ side** goes first and attaches the process to the network: -`--connect ` dials a relay (the path is the auth path, `?jwt=` -carries a token), and `--broadcast ` names the broadcast. A process can +`--connect ` dials a relay (the path is the auth path), `--connect-token` +presents a token (repeat it to present several, see +[Tokens](/bin/relay/auth#tokens)), and `--broadcast ` names the +broadcast. A `?jwt=` in the URL still works, and is how a token reaches a +relay that predates `--connect-token`. A process can instead host sessions with `--listen`, or both at once, admitting clients the way the relay does (see [Authentication](/bin/relay/auth)). Listening imports and exports share the relay's shutdown behavior: SIGINT or SIGTERM sends accepted @@ -77,7 +80,7 @@ ffmpeg -re -i video.mp4 -c copy -f mpegts -pes_payload_size 0 -muxdelay 0 - | \ moq --connect https://relay.example.com/anon --broadcast my-stream.hang export ts | ffplay - # With a token -moq --connect "https://relay.example.com/rooms/1?jwt=$TOKEN" --broadcast alice.hang import ts +moq --connect https://relay.example.com/rooms/1 --connect-token "$TOKEN" --broadcast alice.hang import ts ``` ## Import diff --git a/doc/bin/relay/auth.md b/doc/bin/relay/auth.md index d2c587c8fa3..2c6fd04df21 100644 --- a/doc/bin/relay/auth.md +++ b/doc/bin/relay/auth.md @@ -122,9 +122,18 @@ moq auth verify --key public.jwk --in alice.jwt moq auth serve --key public.jwk # or --key-dir /etc/moq/keys/ or --key-set /etc/moq/keys.jwks ``` -The client dials `https://relay.example.com/rooms/123?jwt=`. A -moq-transport client can instead put the JWT in its SETUP's `AUTHORIZATION -TOKEN` option with Token Type 0. HMAC, RSA, ECDSA, and EdDSA keys all work. A +A native client passes it as `--connect-token ` (or `MOQ_CONNECT_TOKEN`) +and dials `https://relay.example.com/rooms/123`. The first token is the +connection credential, sent in SETUP: moq-lite-07-wip's Token parameter, or +moq-transport's `AUTHORIZATION TOKEN` option with Token Type 0. While the client +also offers a version that cannot carry it (moq-lite before 07), it sends the +same value as `?jwt=`, which `moq auth serve` admits since they match; two +different values are refused. Each further token is presented +[in band](#in-band-renewal) and widens the session's grant. A URL that already +carries a `?jwt=`, such as a cluster peer's, keeps it as the connection +credential, and every configured token then rides in band. Dialing +`?jwt=` directly still works, and reaches relays that predate SETUP +tokens. HMAC, RSA, ECDSA, and EdDSA keys all work. A key can itself be **scoped** at generation (`--root`, `--publish`, `--subscribe`), after which it can never sign a broader token. @@ -135,7 +144,10 @@ a client can present another JWT without reconnecting. Present its replacement before the current grant expires and keep the returned token handle alive. The native `Connection::auth()` handle re-presents retained tokens after a reconnect; the JavaScript session's `auth.add()` presents one for that session. -Older versions refuse in-band tokens. +A native client can instead replace its configured tokens through +`Client::tokens()`: the next dial presents them, a live session presents them in +band and withdraws each old one once its replacement is answered, and a refusal +redials only once the tokens have changed. Older versions refuse in-band tokens. Each accepted token adds its publish and subscribe patterns to the session's union, within its admitted root and SETUP role. A publish-only connection cannot diff --git a/doc/concept/transport.md b/doc/concept/transport.md index 001d828e1c1..e426fdb2fa0 100644 --- a/doc/concept/transport.md +++ b/doc/concept/transport.md @@ -48,8 +48,10 @@ WebSocket stays there until it ends; the next connect races again. ## Raw QUIC (native) Native clients dial the relay directly with the MoQ ALPN, skipping the HTTP/3 -handshake. The URL path and `?jwt=` token travel in the MoQ `SETUP` message +handshake. The URL path and any `?jwt=` token travel in the MoQ `SETUP` message instead of a `CONNECT` request, so the server sees the same thing either way. +On every transport, a SETUP token (moq-lite-07-wip and moq-transport) can carry +the credential in place of the query. `moq-tokio` picks this automatically for `https://` URLs; use `moql://` or `moqt://` to force it. diff --git a/doc/lib/rs/index.md b/doc/lib/rs/index.md index 08059e24cea..164e6a6dbd6 100644 --- a/doc/lib/rs/index.md +++ b/doc/lib/rs/index.md @@ -86,8 +86,12 @@ already queued, then `Client::close` sends the QUIC close. Runnable examples: [`subscribe.rs`](https://github.com/moq-dev/moq/blob/main/rs/hang/examples/subscribe.rs). URLs may be `https://` (QUIC or WebTransport, with a WebSocket fallback), -`moql://`/`moqt://` (raw QUIC), or `iroh://`. A `?jwt=` query carries the -token. `http://` is for a relay on localhost only: it fetches the certificate +`moql://`/`moqt://` (raw QUIC), or `iroh://`. Put credentials in +`connect::Config::tokens` rather than the URL: the first rides SETUP (and +`?jwt=` while an offered version needs it), each other widens the grant in band, +and `Client::tokens().set(...)` replaces them before they expire. A `?jwt=` +already in the URL stays the connection credential, which reaches an older +relay. `http://` is for a relay on localhost only: it fetches the certificate fingerprint unauthenticated, so never send a token over it. QUIC tuning shares the relay's settings; see the [relay reference](/bin/relay/config#quic). diff --git a/drafts/draft-lcurley-moq-lite.md b/drafts/draft-lcurley-moq-lite.md index 122a16e41a7..14ff1b3b8a9 100644 --- a/drafts/draft-lcurley-moq-lite.md +++ b/drafts/draft-lcurley-moq-lite.md @@ -611,7 +611,7 @@ If the publisher advertised no Probe capability (e.g., the congestion controller Either endpoint can open an Auth Stream (0x7) to present a token and learn what it grants. The opener sends a single AUTH message and keeps its side open for as long as the token applies. -Each endpoint SHOULD open one Auth Stream with an empty token right after the session starts, so both learn what the credential the connection already presented (the request URI, a client certificate, or nothing) allows. +Each endpoint SHOULD open one Auth Stream with an empty token right after the session starts, so both learn what the credential the connection already presented (the [Token](#token-parameter) parameter, the request URI, a client certificate, or nothing) allows. The acceptor replies with an AUTH_OK carrying the grant, or an AUTH_ERROR refusing the token. The grant MUST join the acceptor's scope before it sends AUTH_OK. It MAY send further AUTH_OK messages to replace the grant, such as with a lowered expiry, and MAY send an AUTH_ERROR after an AUTH_OK to revoke it; the latest AUTH_OK is the token's grant. @@ -912,6 +912,8 @@ The following Setup Parameters are defined: |------|-----------|-------------| | 0x5 | Hop | Hop ID (i) | |------|-----------|-------------| +| 0x6 | Token | Token (b) | +|------|-----------|-------------| ### Probe Parameter {#probe-parameter} The Probe Parameter advertises the sender's capability level when acting as a publisher on a [Probe Stream](#probe). @@ -973,6 +975,17 @@ Declaring it at setup gives the receiver the peer's identity before any other st Either endpoint MAY send it; a subscriber-only endpoint with no identity MAY omit it, but a publisher SHOULD have a Hop ID regardless (see [ANNOUNCE_OK](#announce-ok)). A relay MUST NOT forward it. +### Token Parameter {#token-parameter} +The Token Parameter carries the client's connection credential, such as a JWT, so a server can authorize the session from the SETUP it already reads before serving. +The Parameter Value is the token's bytes, in a format the endpoints agree on out of band; it MUST NOT be empty, and a receiver MUST close the session with a PROTOCOL_VIOLATION on an empty value. + +It is the credential the empty-token [Auth Stream](#auth-stream) refers to, so the client presents any other token on its own Auth Stream rather than here. +A server SHOULD verify it as it would a credential in the request URI's query. +A client whose request URI already carries a credential SHOULD NOT also send a different one here, and a server MAY refuse a session that presents two different credentials. +The URI is fixed before the version is negotiated, so a client that may negotiate a version without this parameter carries the same credential in the query as well. + +Only the client sends it; a client that receives one MUST close the session with a PROTOCOL_VIOLATION. A relay MUST NOT forward it. + ## ANNOUNCE_REQUEST {#announce-request} A subscriber sends an ANNOUNCE_REQUEST message to indicate it wants to receive announcements for any broadcasts with a path that starts with the requested prefix. @@ -1791,6 +1804,7 @@ The `Message Length` describes the payload size on the wire. - An open Track Stream is interest in the Track until the subscriber closes it, which a subscriber does once its Subscribe Stream has a response. lite-05 and lite-06 implementations do the same. - Assigned `moq-lite-07-wip` as this draft's protocol identifier until it is finalized as `moq-lite-07`. +- Added the client-only Token Setup Parameter (0x6), carrying the connection credential in SETUP instead of only the request URI. - Switched every variable-length integer, including SETUP parameter values, from QUIC's two-bit length prefix to moq-transport's leading-ones encoding, widening the range to 64 bits. - Hid routes with a `.`-prefixed segment below the requested prefix from announce discovery, and added the ANNOUNCE_REQUEST `Hidden` field to opt in. - Restored SUBSCRIBE_DROP (type 0x2), replacing Stream Count on SUBSCRIBE_END. Every sequence is accounted for by a Group Stream, a datagram, or a drop; a publisher SHOULD send a drop as soon as it gives a sequence up. diff --git a/quest/m1/auth/README.md b/quest/m1/auth/README.md index 43ad2a025ab..247291268fc 100644 --- a/quest/m1/auth/README.md +++ b/quest/m1/auth/README.md @@ -145,9 +145,6 @@ because `main` assigned 0x3A to NOT_FETCHABLE first. ## Required - [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 -- [Token in band](/quest/m1/auth/token-in-band.md) - extra credentials ride - AUTH streams: a session starts on the token its SETUP or URL carried and its - AUTH streams add the rest ## Related diff --git a/quest/m1/auth/token-in-band.md b/quest/m1/auth/token-in-band.md deleted file mode 100644 index e5413ae7086..00000000000 --- a/quest/m1/auth/token-in-band.md +++ /dev/null @@ -1,89 +0,0 @@ -# [M] Token in band - -## Goal - -Several credentials can ride one connection. Every client in this repository -takes tokens as configuration separate from the address, puts the first in -its SETUP (the URL query when the negotiated version has no SETUP token), -presents each other one on its own AUTH stream at setup, and the relay admits -the session on that first token and widens it as the streams are answered. Peers without the AUTH stream keep -working unchanged, so nothing is refused and nothing goes silent, and a -session's first requests wait on no AUTH. - -## Plan - -IETF AUTH initially carries only prefix-representable grants. Propagate its -`Unsupported` result for an unrepresentable pattern union; do not retry the -token through the URL, widen it, or leave token admission pending. Lite AUTH -already carries the full pattern grant. - -- Client configuration separates the credential from the address: `moq_tokio::connect::Config` gains `tokens`, repeatable as - `--connect-token` and `MOQ_CONNECT_TOKEN`, the default set for every dial - the client makes. A `?jwt=` in the URL stays a member of the union, which is - how cluster dial targets keep their per-peer credential, and per-dial - extras use the session's `auth().add()` once connected. moq-ffi gains a - `tokens` field on the `MoqClientConfig` record that - the FFI shape (#4697) put in place of every - `MoqClient` setter, mirroring `moq_tokio::connect::Config`, not a new - `set_tokens` setter (mirrored in the wrappers, and reaching the - generated C and C++ bindings and OBS through the generated C++) and `js/net`'s `connect` - options field follow. Decided in the 2026-09-30 audit: no new libmoq API, - since it gets no more feature work. -- Credential refresh belongs with token presentation. Resolve the configured - credential source before each dial. After an authorization refusal, resolve - it once more and retry only when the credential changed; do not loop on a - rejected credential. On an AUTH-capable session, replace a token through - its own AUTH stream so the accepted grant union remains live. Keep `?jwt=` - as the fallback for peers without AUTH. Choose the public credential-source - API while implementing this quest. -- Presenting: ✅ Decided 2026-10-19 (maintainer, - [#5972](https://github.com/moq-dev/moq/pull/5972)): the first configured - token rides SETUP. lite-07-wip gains a SETUP token parameter, filling - `server::Handshake::token` the way moq-transport's AUTHORIZATION TOKEN - setup option (`ietf::token::into_setup`, `USE_VALUE`, token type 0) already - does, so the relay hands it to the auth server at accept. It costs no round - trip: the acceptor already reads the client's SETUP before serving, and a - lite client sends requests without waiting on the server's SETUP. The - version is negotiated (ALPN or the WebTransport subprotocol) before SETUP - is written, but the URL is fixed before negotiation, so the client also - copies the first token into the URL query while any offered version lacks - a SETUP token (every lite version before 07). On raw QUIC and Qmux the - query already rides SETUP's `Path`. A dial URL that already carries a - credential (a cluster's per-peer `?jwt=`) keeps it as the connection - credential instead, and then every configured token gets its own AUTH - stream: the client never appends a second `jwt` or sends a different SETUP - token. A token that went into the URL or SETUP is the connection - credential, and the empty-token stream is its one and only stream; every - other configured token gets its own AUTH stream on an AUTH-capable session, - so no token is ever granted twice. While the URL also carries it, the auth - server sees the same credential twice: `moq auth serve` admits a SETUP - token equal to the `?jwt=` value and refuses two different ones - (`serve::Refusal::TwoTokens`). Never send different values in the two - places. A token in SETUP is bounded by SETUP's Message Length cap and - reaches the auth server exactly as a URL token does, so an unauthenticated - client gains nothing it could not already send in the URL. Dropped: the - earlier "always in the CONNECT request" rule and its auth-header plan. -- The relay admits on the URL, then widens. An anonymous connection is - admitted with the public grant when one is configured and refused at - connect otherwise, as today: a client with tokens always carries one in - SETUP or the URL, so the relay needs no admission deadline. The URL query - and the SETUP token stay the connect-time credentials in the - `moq_auth::Request`. -- A URL credential is still refused at connect when both arms reject it, and - an in-band refusal is an AUTH_ERROR after connect. -- Docs: `doc/bin/cli.md`, `doc/bin/relay/auth.md` (that the URL token is one - member of the union), `doc/lib/*` client - configuration, and the example invocations carrying `?jwt=` in `doc/`, - `demo/`, and the READMEs, keeping the URL form documented as the way to - reach an older relay. -- Spec the SETUP token parameter in `drafts/draft-lcurley-moq-lite.md`'s - lite-07-wip SETUP, and update `js/net` beside Rust. -- Tests: a client with a configured token offering only lite-07 against an - AUTH-capable relay is scoped exactly as the URL variant, with the token in - SETUP and none in the URL; the same client against a lite-05 relay still - authenticates through the URL; a moq-transport client offering a draft - without AUTH, carrying the token in both the URL and the setup option, is - admitted by `moq auth serve`; two configured tokens union; the - cross-language harness runs with tokens configured. - -Additive. Wire: a lite-07-wip SETUP parameter. diff --git a/rs/moq-ffi/src/session.rs b/rs/moq-ffi/src/session.rs index 0f2c9048aee..137cb781f29 100644 --- a/rs/moq-ffi/src/session.rs +++ b/rs/moq-ffi/src/session.rs @@ -23,6 +23,11 @@ pub struct MoqClientConfig { /// Empty offers every supported version; [`moq_versions`] lists them. #[uniffi(default = [])] pub versions: Vec, + /// Credentials every dial presents. The first is the connection credential, unless the URL + /// carries its own `?jwt=`; each other one widens the session's grant. Replace them with + /// [`MoqClient::set_tokens`]. + #[uniffi(default = [])] + pub tokens: Vec, /// How long one connect may take, dial and handshake together, in microseconds. 0 waits /// forever. #[uniffi(default = None)] @@ -284,6 +289,18 @@ pub(crate) fn parse_versions(versions: &[String]) -> Result>>; +#[cfg(not(target_arch = "wasm32"))] +fn parse_tokens(tokens: Vec) -> Result, MoqError> { + tokens + .into_iter() + .map(|token| { + token.try_into().map_err(|err: moq_tokio::connect::EmptyToken| MoqError::Config { + reason: err.to_string(), + }) + }) + .collect() +} + /// Native QUIC/WebTransport client state: the configured endpoint and the wired origins. #[cfg(not(target_arch = "wasm32"))] struct Client { @@ -317,6 +334,7 @@ impl Client { connect.bind = Some(bind); } connect.version = parse_versions(&config.versions)?; + connect.tokens = parse_tokens(config.tokens)?; connect.timeout = config.timeout_us.map(std::time::Duration::from_micros); connect.race = config.race_us.map(std::time::Duration::from_micros); connect.resolution_delay = config.resolution_delay_us.map(std::time::Duration::from_micros); @@ -590,6 +608,22 @@ mod tests { ..Default::default() }; assert!(matches!(MoqClient::new(missing), Err(MoqError::Config { .. }))); + + let empty = MoqClientConfig { + tokens: vec![String::new()], + ..Default::default() + }; + assert!(matches!(MoqClient::new(empty), Err(MoqError::Config { .. }))); + } + + #[test] + fn set_tokens_refuses_an_empty_token() { + let client = MoqClient::new(MoqClientConfig::default()).unwrap(); + client.set_tokens(vec!["a".into(), "b".into()]).unwrap(); + assert!(matches!( + client.set_tokens(vec![String::new()]), + Err(MoqError::Config { .. }) + )); } #[test] @@ -598,6 +632,7 @@ mod tests { MoqClient::new(MoqClientConfig { bind: Some("127.0.0.1:0".into()), versions: vec!["moq-lite-03".into()], + tokens: vec!["credential".into()], once: true, backoff: MoqBackoff { timeout_us: Some(0), @@ -629,6 +664,8 @@ impl Client { let MoqClientConfig { bind, versions, + // Presenting tokens needs the native client's dial and AUTH handling. + tokens, timeout_us, race_us, resolution_delay_us, @@ -647,6 +684,7 @@ impl Client { ..tls.clone() }; if bind.is_some() + || !tokens.is_empty() || timeout_us.is_some() || race_us.is_some() || resolution_delay_us.is_some() @@ -753,6 +791,10 @@ pub struct MoqClient { /// Shared with the state, which the `connect` in flight holds locked. #[cfg(not(target_arch = "wasm32"))] dialing: Dialing, + /// Held outside the task, which a connect holds for as long as it dials, so a refresh + /// can land mid-dial. + #[cfg(not(target_arch = "wasm32"))] + tokens: moq_tokio::connect::Tokens, } #[uniffi::export] @@ -764,10 +806,30 @@ impl MoqClient { Ok(Arc::new(Self { #[cfg(not(target_arch = "wasm32"))] dialing: client.dialing.clone(), + #[cfg(not(target_arch = "wasm32"))] + tokens: client.client.tokens(), task: Task::new(client), })) } + /// Replace the credentials every dial presents, such as before the current ones expire. + /// + /// The next dial presents them, and a live session that supports in-band AUTH presents + /// them at once, withdrawing each replaced token once its replacements are answered. + /// After an authorization refusal the client redials only if they changed. + pub fn set_tokens(&self, tokens: Vec) -> Result<(), MoqError> { + #[cfg(not(target_arch = "wasm32"))] + { + self.tokens.set(parse_tokens(tokens)?); + Ok(()) + } + #[cfg(target_arch = "wasm32")] + { + let _ = tokens; + Err(MoqError::Unsupported) + } + } + /// Connect to a MoQ server and wait for the session to be established. /// /// A native session automatically reconnects with backoff when the transport drops diff --git a/rs/moq-relay/tests/integration/auth_lifetime.rs b/rs/moq-relay/tests/integration/auth_lifetime.rs index 9f158355a0b..084fc7b1cd1 100644 --- a/rs/moq-relay/tests/integration/auth_lifetime.rs +++ b/rs/moq-relay/tests/integration/auth_lifetime.rs @@ -152,6 +152,12 @@ async fn spawn_relay_with( let mut config = moq_tokio::listen::Config::default(); config.tcp.bind = Some("127.0.0.1:0".parse().expect("parse addr")); + // Opt in to lite-07 beside the defaults, so a client pinned to it can reach the AUTH stream. + config.version = moq_net::Versions::all() + .iter() + .copied() + .chain(["moq-lite-07-wip".parse().unwrap()]) + .collect(); let server = config.init(Default::default()).expect("server init"); let mut server = server.listen().await.expect("listen"); let port = server.tcp_local_addr().expect("TCP listener is configured").port(); @@ -446,6 +452,61 @@ async fn forwards_the_setup_token() { } } +/// A configured token is the connection credential: on lite-07 it reaches the auth server +/// as the SETUP token with nothing in the query, and on lite-06, which has no SETUP token, +/// in the query as a `?jwt=`. +#[tokio::test] +async fn a_configured_token_is_the_connection_credential() { + let token = moq_auth::Token { + kind: moq_auth::Token::OUT_OF_BAND, + value: b"credential".to_vec(), + }; + for (versions, setup, query) in [ + (vec!["moq-lite-07-wip"], Some(token.clone()), None), + (vec!["moq-lite-06"], None, Some("jwt=credential")), + ] { + let script = Script::new(grant(Duration::from_secs(3600))); + // Over SETUP, anonymous admission fails, so an established session proves the token + // was used. The scripted server cannot check a query token, so that case only asserts + // what it was sent. + if let Some(token) = &setup { + script.on_connect(Answer::Token(grant(Duration::from_secs(3600)), token.clone())); + } + let (port, relay) = spawn_relay(build_auth(script.spawn().await)).await; + + let mut config = moq_tokio::connect::Config::default(); + config.once = Some(true); + config.version = versions.iter().map(|version| version.parse().unwrap()).collect(); + config.tokens = vec!["credential".parse().unwrap()]; + let client = config.init(Default::default()).unwrap(); + let url: url::Url = format!("tcp://127.0.0.1:{port}/room").parse().unwrap(); + let session = tokio::time::timeout( + TIMEOUT, + client + .with_subscriber(moq_tokio::origin::spawn()) + .connect(url) + .established(), + ) + .await + .expect("connect timeout") + .expect("the configured token was refused"); + // A lite client is live before admission answers, so give a refusal time to land. + assert!( + tokio::time::timeout(Duration::from_millis(500), session.closed()) + .await + .is_err(), + "{versions:?}: the relay refused the configured token" + ); + + let seen = script.seen.lock().unwrap().clone(); + let connect = seen.iter().find(|r| r.event == Event::Connect).expect("a connect"); + assert_eq!(connect.token, setup, "{versions:?}"); + assert_eq!(connect.query.as_deref(), query, "{versions:?}"); + session.abort(moq_net::Error::Cancel); + relay.abort(); + } +} + /// A refusal at connect, a 5xx, and a garbage reply all refuse the session. #[tokio::test] async fn refusals_and_outages_refuse_at_connect() { diff --git a/test/interop/interop.sh b/test/interop/interop.sh index 59ecb277ef2..8eb71026cc0 100755 --- a/test/interop/interop.sh +++ b/test/interop/interop.sh @@ -601,6 +601,16 @@ token_url() { printf '%s/?jwt=%s' "$URL" "$token" } +# Set `connect` to moq-cli's arguments for , moving a `?jwt=` token to +# `--connect-token` so it rides SETUP on $AUTH_VERSION. The binding clients keep it +# in the URL, which covers the query form. +rust_connect() { + case "$1" in + *'?jwt='*) connect=(--connect "${1%%\?jwt=*}" --connect-token "${1#*\?jwt=}") ;; + *) connect=(--connect "$1") ;; + esac +} + # moq-cli logs each grant it receives over AUTH at debug, under `moq_net::auth`. CLI_LOG="${RUST_LOG:-info},moq_net::auth=debug" @@ -671,7 +681,9 @@ run_publisher() { case "$lang" in rust) # A finished import stays up for its max-age; `stop_publisher` allows 10 s to close. - ffmpeg_h264 | RUST_LOG="$CLI_LOG" "$MOQ" --connect "$url" --connect-version "$AUTH_VERSION" \ + local connect + rust_connect "$url" + ffmpeg_h264 | RUST_LOG="$CLI_LOG" "$MOQ" "${connect[@]}" --connect-version "$AUTH_VERSION" \ --broadcast "$broadcast" import --max-age 1s avc3 ;; python) @@ -818,8 +830,9 @@ PYCODE [[ -s "$HARNESS_RUN/$broadcast.raw" ]] return fi - local n - n=$(RUST_LOG="$CLI_LOG" timeout -k 3 "$TIMEOUT" "${INTEROP_SUB_MOQ:-$MOQ}" --connect "$url" \ + local n connect + rust_connect "$url" + n=$(RUST_LOG="$CLI_LOG" timeout -k 3 "$TIMEOUT" "${INTEROP_SUB_MOQ:-$MOQ}" "${connect[@]}" \ --connect-version "$AUTH_VERSION" --broadcast "$broadcast" export fmp4 | head -c 1 | wc -c | tr -d ' ' || true) [[ "${n:-0}" -ge 1 ]] ;; From 8ae9049bde529131e01278f90f190975c478baec Mon Sep 17 00:00:00 2001 From: Melanie Riise Date: Tue, 20 Oct 2026 20:30:11 -0700 Subject: [PATCH 29/45] feat(bindings): mirror configured tokens in every wrapper Python, Go, Kotlin, Dart, and Swift take tokens on the client config and expose set_tokens, and each binding page documents them. Co-Authored-By: Claude Opus 5.5 --- dart/moq/lib/src/client.dart | 18 +++++++++++++++ doc/lib/cpp/index.md | 2 ++ doc/lib/dart/index.md | 1 + doc/lib/go/index.md | 1 + doc/lib/kt/index.md | 1 + doc/lib/py/index.md | 1 + doc/lib/swift/index.md | 1 + go/wrapper/client.go | 21 +++++++++++++++++ .../kotlin/dev/moq/Aliases.kt | 2 +- .../jvmAndAndroidMain/kotlin/dev/moq/Moq.kt | 11 +++++++++ py/moq-rs/moq/client.py | 23 +++++++++++++++++++ py/moq-rs/tests/test_local.py | 8 +++++++ rs/moq-ffi/src/session.rs | 8 ++++--- rs/moq-net/src/lite/setup.rs | 10 ++++++-- rs/moq-tokio/src/connect/token.rs | 5 +++- rs/moq-tokio/src/connection/auth.rs | 10 ++++++-- rs/moq-tokio/tests/integration/tokens.rs | 9 ++++++-- swift/Sources/Moq/Client.swift | 17 ++++++++++++++ 18 files changed, 138 insertions(+), 11 deletions(-) diff --git a/dart/moq/lib/src/client.dart b/dart/moq/lib/src/client.dart index c8d069dac90..dcdcb666dd9 100644 --- a/dart/moq/lib/src/client.dart +++ b/dart/moq/lib/src/client.dart @@ -52,6 +52,13 @@ final class ConnectOptions { /// Null offers every supported version, which [versions] lists. final List? versions; + /// Credentials every dial presents. The first is the connection credential, + /// sent in SETUP and, while an offered version can't carry it there, the + /// URL's `?jwt=`; each other one widens the session's grant over in-band + /// AUTH. A URL that already carries `?jwt=` keeps it, and then every token + /// rides AUTH. Replace them before they expire with [Moq.setTokens]. + final List? tokens; + /// How long one connect may take, dial and handshake together; zero waits forever. final Duration? timeout; @@ -119,6 +126,7 @@ final class ConnectOptions { this.tlsHostName, this.bind, this.versions, + this.tokens, this.timeout, this.race, this.resolutionDelay, @@ -149,6 +157,7 @@ final class ConnectOptions { return MoqClientConfig( bind: bind, versions: versions ?? const [], + tokens: tokens ?? const [], timeoutUs: us(timeout, 'timeout'), raceUs: us(race, 'race'), resolutionDelayUs: us(resolutionDelay, 'resolutionDelay'), @@ -261,6 +270,15 @@ final class Moq { /// on every reconnect. Auth auth() => session.auth(); + /// Replace the credentials every dial presents ([ConnectOptions.tokens]), + /// such as before the current ones expire. + /// + /// The next dial presents them, and a live session with in-band AUTH + /// presents them at once, withdrawing each replaced token once its + /// replacements are answered. After an authorization refusal the client + /// redials only if they changed. An empty token throws `MoqException.Config`. + void setTokens(List tokens) => _client.setTokens(tokens: tokens); + /// Drain within one second, then stop the client. /// /// Explicit `moq-lite-07-wip` waits for the peer to receive finished tails; older diff --git a/doc/lib/cpp/index.md b/doc/lib/cpp/index.md index ab3b7c2e2f6..ff8a1b65caa 100644 --- a/doc/lib/cpp/index.md +++ b/doc/lib/cpp/index.md @@ -204,6 +204,8 @@ Obtain a dynamic track request’s `demand()` before accepting it to watch subsc On `moq-lite-07-wip` (opt in with `ClientConfig::versions`) and on moq-transport draft-17+ with the [MoQ Auth extension](/draft/moq-auth), `session->auth()->grant()` is the `Grant` the relay sent back, empty until it answers and while a client session reconnects; `grant_changed()` waits for the next one. `add(token)` presents a fresh token without reconnecting and resolves to an `AuthToken` whose grant joins the union until `cancel()`; it is presented again on every reconnect. A refusal resolves to an `Error` carrying the relay's code. +Set `ClientConfig::tokens` instead of putting the credential in the URL. The first is the connection credential, sent in SETUP and, while an offered version can't carry it there (lite before 07), as the URL's `?jwt=`; each other token widens the grant over AUTH. `client->set_tokens(...)` replaces them before they expire: the next dial presents them, a live AUTH session at once, and after an authorization refusal the client redials only if they changed. A `?jwt=` already in the URL stays the connection credential, which is how to reach a relay older than this release. + ## Stats `session->stats()` reports the transport counters, each a `std::optional` that is empty when the transport does not report it, plus the negotiated protocol in `version`. While a client session reconnects, `session->failure()` returns why as a `moq::Error`, and while its first `connect` is still retrying, `client->failure()` does. diff --git a/doc/lib/dart/index.md b/doc/lib/dart/index.md index a7038d555d5..00d05863b38 100644 --- a/doc/lib/dart/index.md +++ b/doc/lib/dart/index.md @@ -138,6 +138,7 @@ shows which module holds each. - **Closing.** `await moq.close()` or `session.shutdown()` drains for up to one second and reports any error. `session.cancel(code: 0)` closes at once. Finish or abort live tracks first. Cancelling a stream subscription releases its native cursor. Application delivery requires explicitly selecting `moq-lite-07-wip` on both peers; older Lite versions (including default lite-06) and IETF wait only for transport ACKs. - **Stats.** `session.stats()` reports `rttUs`, `estimatedSendRateBps`, and the byte and packet counters (`bytesSent`, `bytesReceived`, `bytesLost`, `packetsSent`, `packetsReceived`, `packetsLost`). A field is `null` when the transport does not report it, which is not the same as zero. `version` names the negotiated protocol, and while a client session reconnects `session.failure()` says why. Before the first connect, `client.failure()` says why the dial is retrying. Hold the owned `BandwidthConsumer` returned by `session.recvBandwidth()` to receive bandwidth estimates. `peek()` reads the current estimate in bits per second and `changed()` waits for a new one. Release the consumer or call `cancel()` when finished; releasing the last consumer stops PROBE; the session and stats snapshots do not request it. Client consumers survive reconnects and report no estimate while disconnected. - **Authorization.** On `moq-lite-07-wip` (opt in with `ConnectOptions(versions: ...)`) and on moq-transport draft-17+ with the [MoQ Auth extension](/draft/moq-auth), listen to `moq.auth().grants()` for the `Grant` the relay sent back, `null` until it answers and while a client session reconnects. `await auth().add(token: ...)` presents a fresh token without reconnecting, returns an `AuthToken` whose grant joins the union until `cancel()`, and is presented again on every reconnect. A refusal throws with the relay's code. +- **Tokens.** Pass `ConnectOptions(tokens: [...])` instead of putting the credential in the URL. The first is the connection credential, sent in SETUP and, while an offered version can't carry it there (lite before 07), as the URL's `?jwt=`; each other token widens the grant over AUTH. `moq.setTokens([...])` replaces them before they expire: the next dial presents them, a live AUTH session at once, and after an authorization refusal the client redials only if they changed. A `?jwt=` already in the URL stays the connection credential, which is how to reach a relay older than this release. - **Errors.** Read diagnostic payloads from `reason` and structured protocol failures from `details`. - **Disabled renditions.** Audio and video convenience decoders reject a catalog configuration with `enabled: false`. Select an enabled rendition from the latest catalog before opening a decoder. - **Track demand.** Obtain a dynamic track request’s `demand()` before accepting it to watch subscribers through the handoff to its producer. The watch does not keep the track alive and ends when the track closes or the request is rejected. diff --git a/doc/lib/go/index.md b/doc/lib/go/index.md index ae7cc6a3776..bcc9e9ffd74 100644 --- a/doc/lib/go/index.md +++ b/doc/lib/go/index.md @@ -128,6 +128,7 @@ shows which module holds each. - **Closing.** `client.Close()` drains for up to one second and returns an error on failure. `session.Shutdown(ctx)` does the same, and `session.Cancel(code)` closes at once. Finish or abort live tracks first. Application delivery requires explicitly selecting `moq-lite-07-wip` on both peers; older Lite versions (including default lite-06) and IETF wait only for transport ACKs. - **Stats.** `Session().Stats()` reports `RTT`, `EstimatedSendRateBps`, and the byte and packet counters (`BytesSent`, `BytesReceived`, `BytesLost`, `PacketsSent`, `PacketsReceived`, `PacketsLost`). A field is nil when the transport does not report it, which is not the same as zero. `Version` names the negotiated protocol, and while a client session reconnects `Session().Failure()` says why. Hold the owned `BandwidthConsumer` returned by `Session().RecvBandwidth()` to receive bandwidth estimates. `Peek()` reads the current estimate in bits per second and `Changed(ctx)` waits for a new one. Calling `Close()` on the last consumer stops PROBE; the session and stats snapshots do not request it. Client consumers survive reconnects and report no estimate while disconnected. - **Authorization.** On `moq-lite-07-wip` (opt in with `moq.WithVersions`) and on moq-transport draft-17+ with the [MoQ Auth extension](/draft/moq-auth), `Session().Auth().Grant()` is the `Grant` the relay sent back, nil until it answers and while a client session reconnects; `GrantChanged(ctx)` waits for the next one. `Add(ctx, token)` presents a fresh token without reconnecting, returns an `AuthToken` whose grant joins the union until `Close()`, and is presented again on every reconnect. A refusal returns the relay's code. +- **Tokens.** Pass `moq.WithTokens(...)` instead of putting the credential in the URL. The first is the connection credential, sent in SETUP and, while an offered version can't carry it there (lite before 07), as the URL's `?jwt=`; each other token widens the grant over AUTH. `client.SetTokens(...)` replaces them before they expire: the next dial presents them, a live AUTH session at once, and after an authorization refusal the client redials only if they changed. A `?jwt=` already in the URL stays the connection credential, which is how to reach a relay older than this release. - **Errors.** Read diagnostic payloads from `Reason` and structured protocol failures from `Details`. - **Disabled renditions.** Audio and video convenience decoders reject a catalog configuration with `enabled: false`. Select an enabled rendition from the latest catalog before opening a decoder. - **Track demand.** Obtain a dynamic track request’s `Demand()` before accepting it to watch subscribers through the handoff to its producer. The watch does not keep the track alive and ends when the track closes or the request is rejected. diff --git a/doc/lib/kt/index.md b/doc/lib/kt/index.md index ea0004a54a9..f1590b880b8 100644 --- a/doc/lib/kt/index.md +++ b/doc/lib/kt/index.md @@ -100,6 +100,7 @@ shows which module holds each. - **Closing.** Suspending `moq.shutdown()` drains for up to one second and throws on failure. `Moq.close()`, and so `use { }`, cancels at once. Finish or abort live tracks first. Application delivery requires explicitly selecting `moq-lite-07-wip` on both peers; older Lite versions (including default lite-06) and IETF wait only for transport ACKs. - **Stats.** `session.stats()` reports `rttUs`, `estimatedSendRateBps`, and the byte and packet counters (`bytesSent`, `bytesReceived`, `bytesLost`, `packetsSent`, `packetsReceived`, `packetsLost`). A field is `null` when the transport does not report it, which is not the same as zero. `version` names the negotiated protocol, and while a client session reconnects `session.failure()` says why. Before the first connect, `client.failure()` says why the dial is retrying. Hold the owned `BandwidthConsumer` returned by `session.recvBandwidth()` to receive bandwidth estimates. `peek()` reads the current estimate in bits per second and `changed()` waits for a new one. Release the consumer or call `cancel()` when finished; releasing the last consumer stops PROBE; the session and stats snapshots do not request it. Client consumers survive reconnects and report no estimate while disconnected. - **Authorization.** On `moq-lite-07-wip` (opt in with `ClientConfig(versions = ...)`) and on moq-transport draft-17+ with the [MoQ Auth extension](/draft/moq-auth), collect `moq.auth().grants()` for the `Grant` the relay sent back, `null` until it answers and while a client session reconnects. Suspending `auth().add(token)` presents a fresh token without reconnecting, returns an `AuthToken` whose grant joins the union until `cancel()`, and is presented again on every reconnect. A refusal throws with the relay's code. +- **Tokens.** Pass `ClientConfig(tokens = listOf(...))` instead of putting the credential in the URL. The first is the connection credential, sent in SETUP and, while an offered version can't carry it there (lite before 07), as the URL's `?jwt=`; each other token widens the grant over AUTH. `moq.setTokens(listOf(...))` replaces them before they expire: the next dial presents them, a live AUTH session at once, and after an authorization refusal the client redials only if they changed. A `?jwt=` already in the URL stays the connection credential, which is how to reach a relay older than this release. - **Errors.** Read diagnostic payloads from `reason` and structured protocol failures from `details`. - **Disabled renditions.** Audio and video convenience decoders reject a catalog configuration with `enabled: false`. Select an enabled rendition from the latest catalog before opening a decoder. - **Track demand.** Obtain a dynamic track request’s `demand()` before accepting it to watch subscribers through the handoff to its producer. The watch does not keep the track alive and ends when the track closes or the request is rejected. diff --git a/doc/lib/py/index.md b/doc/lib/py/index.md index 3b0974b412f..2134cb85c6d 100644 --- a/doc/lib/py/index.md +++ b/doc/lib/py/index.md @@ -115,6 +115,7 @@ shows which module holds each. - **Closing.** `await session.shutdown()` drains for up to one second and raises on failure. `cancel(code)` closes at once. Finish or abort live tracks first. Application delivery requires explicitly selecting `moq-lite-07-wip` on both peers; older Lite versions (including default lite-06) and IETF wait only for transport ACKs. - **Stats.** `session.stats()` reports `rtt`, `estimated_send_rate_bps`, and the byte and packet counters (`bytes_sent`, `bytes_received`, `bytes_lost`, `packets_sent`, `packets_received`, `packets_lost`). A field is `None` when the transport does not report it, which is not the same as zero. `version` names the negotiated protocol, and while a client session reconnects `session.failure()` says why. Before the first connect, `client.failure()` says why the dial is retrying. Hold the owned `Bandwidth.Consumer` returned by `session.recv_bandwidth()` to receive bandwidth estimates. `peek()` reads the current estimate in bits per second and `changed()` waits for a new one. Release the consumer or call `cancel()` when finished; releasing the last consumer stops PROBE; the session and stats snapshots do not request it. Client consumers survive reconnects and report no estimate while disconnected. - **Authorization.** On `moq-lite-07-wip` (opt in with `versions=`) and on moq-transport draft-17+ with the [MoQ Auth extension](/draft/moq-auth), `session.auth().grant()` is the `Grant` the relay sent back, `None` until it answers and while a client session reconnects; `await auth.grant_changed()` waits for the next one. `await auth.add(token)` presents a fresh token without reconnecting, returns an `AuthToken` whose grant joins the union until `cancel()`, and is presented again on every reconnect. A refusal raises with the relay's code. +- **Tokens.** Pass `tokens=[...]` instead of putting the credential in the URL. The first is the connection credential, sent in SETUP and, while an offered version can't carry it there (lite before 07), as the URL's `?jwt=`; each other token widens the grant over AUTH. `client.set_tokens([...])` replaces them before they expire: the next dial presents them, a live AUTH session at once, and after an authorization refusal the client redials only if they changed. A `?jwt=` already in the URL stays the connection credential, which is how to reach a relay older than this release. - **Errors.** Read diagnostic payloads from `reason` and structured protocol failures from `details`. - **Disabled renditions.** Audio and video convenience decoders reject a catalog configuration with `enabled: false`. Select an enabled rendition from the latest catalog before opening a decoder. - **Track demand.** Obtain a dynamic track request’s `demand()` before accepting it to watch subscribers through the handoff to its producer. The watch does not keep the track alive and ends when the track closes or the request is rejected. diff --git a/doc/lib/swift/index.md b/doc/lib/swift/index.md index 3fe646d2d46..524986ba211 100644 --- a/doc/lib/swift/index.md +++ b/doc/lib/swift/index.md @@ -99,6 +99,7 @@ shows which module holds each. - **Closing.** `try await session.shutdown()` drains for up to one second and throws on failure. `session.cancel(code:)` closes at once. Finish or abort live tracks first. Application delivery requires explicitly selecting `moq-lite-07-wip` on both peers; older Lite versions (including default lite-06) and IETF wait only for transport ACKs. - **Stats.** `session.stats()` reports `rttUs`, `estimatedSendRateBps`, and the byte and packet counters (`bytesSent`, `bytesReceived`, `bytesLost`, `packetsSent`, `packetsReceived`, `packetsLost`). A field is `nil` when the transport does not report it, which is not the same as zero. `version` names the negotiated protocol, and while a client session reconnects `session.failure()` says why. Before the first connect, `client.failure()` says why the dial is retrying. Hold the owned `Bandwidth.Consumer` returned by `session.recvBandwidth()` to receive bandwidth estimates. `peek()` reads the current estimate in bits per second and `changed()` waits for a new one. Release the consumer or call `cancel()` when finished; releasing the last consumer stops PROBE; the session and stats snapshots do not request it. Client consumers survive reconnects and report no estimate while disconnected. - **Authorization.** On `moq-lite-07-wip` (opt in with `Client(versions:)`) and on moq-transport draft-17+ with the [MoQ Auth extension](/draft/moq-auth), `session.auth().grant()` is the `Grant` the relay sent back, `nil` until it answers and while a client session reconnects; `try await auth.grantChanged()` waits for the next one. `try await auth.add(token:)` presents a fresh token without reconnecting, returns an `AuthToken` whose grant joins the union until `cancel()`, and is presented again on every reconnect. A refusal throws with the relay's code. +- **Tokens.** Pass `Client(tokens: [...])` instead of putting the credential in the URL. The first is the connection credential, sent in SETUP and, while an offered version can't carry it there (lite before 07), as the URL's `?jwt=`; each other token widens the grant over AUTH. `try client.setTokens([...])` replaces them before they expire: the next dial presents them, a live AUTH session at once, and after an authorization refusal the client redials only if they changed. A `?jwt=` already in the URL stays the connection credential, which is how to reach a relay older than this release. - **Errors.** Diagnostic cases use the `reason:` argument label; match their associated value to inspect it. Protocol errors carry `details`. - **Disabled renditions.** Audio and video convenience decoders reject a catalog configuration with `enabled: false`. Select an enabled rendition from the latest catalog before opening a decoder. - **Track demand.** Obtain a dynamic track request’s `demand()` before accepting it to watch subscribers through the handoff to its producer. The watch does not keep the track alive and ends when the track closes or the request is rejected. diff --git a/go/wrapper/client.go b/go/wrapper/client.go index 050107de43d..928b050872e 100644 --- a/go/wrapper/client.go +++ b/go/wrapper/client.go @@ -24,6 +24,7 @@ type clientConfig struct { tlsHostName *string bind *string versions []string + tokens []string timeout *time.Duration race *time.Duration resolutionDelay *time.Duration @@ -60,6 +61,7 @@ func (c *clientConfig) ffi() (ffi.MoqClientConfig, error) { cfg := ffi.MoqClientConfig{ Bind: c.bind, Versions: c.versions, + Tokens: c.tokens, TimeoutUs: us("connect timeout", c.timeout), RaceUs: us("race", c.race), ResolutionDelayUs: us("resolution delay", c.resolutionDelay), @@ -210,6 +212,16 @@ func WithVersions(versions ...string) ClientOption { return func(c *clientConfig) { c.versions = offered } } +// WithTokens sets the credentials every dial presents. The first is the +// connection credential, sent in SETUP and, while an offered version can't +// carry it there, the URL's ?jwt=; each other one widens the session's grant +// over in-band AUTH. A URL that already carries ?jwt= keeps it, and then every +// token rides AUTH. Replace them before they expire with [Client.SetTokens]. +func WithTokens(tokens ...string) ClientOption { + presented := append([]string(nil), tokens...) + return func(c *clientConfig) { c.tokens = presented } +} + // WithConnectTimeout bounds one connect attempt, dial and handshake together. // Zero waits forever, and a negative timeout fails Dial. func WithConnectTimeout(timeout time.Duration) ClientOption { @@ -389,6 +401,15 @@ func (c *Client) RequestBroadcast(ctx context.Context, path string) (*BroadcastC return c.consumer.RequestBroadcast(ctx, path) } +// SetTokens replaces the credentials every dial presents, such as before the +// current ones expire. The next dial presents them, and a live session with +// in-band AUTH presents them at once, withdrawing each replaced token once its +// replacements are answered. After an authorization refusal the client redials +// only if they changed. An empty token is refused. +func (c *Client) SetTokens(tokens ...string) error { + return c.inner.SetTokens(append([]string(nil), tokens...)) +} + // Session returns the underlying session. Hold the client (or session) to keep // the connection alive. func (c *Client) Session() *Session { diff --git a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt index a47e258b1d8..4683533c20f 100644 --- a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt +++ b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt @@ -11,7 +11,7 @@ package dev.moq // listen facade (see Server.kt), which exposes the raw handle as `server`. /** A MoQ client built from a [ClientConfig]: connect it to a relay. */ typealias Client = uniffi.moq.MoqClient -/** Client configuration: bind address, versions, TLS, QUIC, WebSocket, reconnect pacing, and origins. */ +/** Client configuration: bind address, versions, tokens, TLS, QUIC, WebSocket, reconnect pacing, and origins. */ typealias ClientConfig = uniffi.moq.MoqClientConfig /** Certificate trust and the mTLS identity for a [ClientConfig]. */ typealias ClientTls = uniffi.moq.MoqClientTls diff --git a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Moq.kt b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Moq.kt index 22af44a7a77..79100431779 100644 --- a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Moq.kt +++ b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Moq.kt @@ -76,6 +76,17 @@ class Moq internal constructor( */ fun auth(): Auth = session.auth() + /** + * Replace the credentials every dial presents ([ClientConfig.tokens]), such as before the + * current ones expire. + * + * The next dial presents them, and a live session with in-band AUTH presents them at once, + * withdrawing each replaced token once its replacements are answered. After an + * authorization refusal the client redials only if they changed. An empty token throws + * `MoqException.Config`. + */ + fun setTokens(tokens: List) = client.setTokens(tokens) + /** * Drain finished tracks within one second, throwing on failure. * Explicit `moq-lite-07-wip` waits for the peer to receive finished tails; older diff --git a/py/moq-rs/moq/client.py b/py/moq-rs/moq/client.py index 2fc83120bdc..c563fadd46e 100644 --- a/py/moq-rs/moq/client.py +++ b/py/moq-rs/moq/client.py @@ -69,6 +69,12 @@ class Client: ``versions`` restricts the protocol versions offered, most preferred first, spelled like ``"moq-lite-03"``; empty offers every supported version, which :func:`versions` lists. + ``tokens`` are credentials every dial presents. The first is the connection credential, + sent in SETUP and, while an offered version can't carry it there, the URL's ``?jwt=``; + each other one widens the session's grant over in-band AUTH. A URL that already carries + ``?jwt=`` keeps it, and then every token rides AUTH. Replace them before they expire + with :meth:`set_tokens`. + ``timeout`` bounds one connect, dial and handshake together (zero waits forever). ``race`` and ``resolution_delay`` pace Happy Eyeballs. ``tls_host_name`` verifies the certificate against another name (SNI). ``idle_timeout``, ``keep_alive`` (zero disables @@ -92,6 +98,7 @@ def __init__( tls_host_name: str | None = None, bind: str | None = None, versions: Sequence[str] = (), + tokens: Sequence[str] = (), timeout: timedelta | None = None, race: timedelta | None = None, resolution_delay: timedelta | None = None, @@ -116,6 +123,7 @@ def __init__( self._config = MoqClientConfig( bind=bind, versions=_strs(versions, "versions"), + tokens=_strs(tokens, "tokens"), timeout_us=_opt_us(timeout, "timeout"), race_us=_opt_us(race, "race"), resolution_delay_us=_opt_us(resolution_delay, "resolution_delay"), @@ -188,6 +196,19 @@ async def __aexit__(self, *exc) -> None: self._inner.cancel() self._inner = None + def set_tokens(self, tokens: Sequence[str]) -> None: + """Replace the credentials every dial presents, such as before the current ones expire. + + The next dial presents them, and a live session with in-band AUTH presents them at + once, withdrawing each replaced token once its replacements are answered. After an + authorization refusal the client redials only if they changed. + """ + tokens = _strs(tokens, "tokens") + if self._inner is None: + self._config.tokens = tokens + else: + self._inner.set_tokens(tokens) + def create_broadcast(self, path: str) -> BroadcastProducer: """Create an unannounced broadcast at ``path``, invisible until announced. Announce it after populating tracks. @@ -251,6 +272,7 @@ def connect( tls_host_name: str | None = None, bind: str | None = None, versions: Sequence[str] = (), + tokens: Sequence[str] = (), timeout: timedelta | None = None, race: timedelta | None = None, resolution_delay: timedelta | None = None, @@ -287,6 +309,7 @@ def connect( tls_host_name=tls_host_name, bind=bind, versions=versions, + tokens=tokens, timeout=timeout, race=race, resolution_delay=resolution_delay, diff --git a/py/moq-rs/tests/test_local.py b/py/moq-rs/tests/test_local.py index 38621566fa3..155a5099228 100644 --- a/py/moq-rs/tests/test_local.py +++ b/py/moq-rs/tests/test_local.py @@ -809,6 +809,14 @@ def test_public_api_exports(): assert client._config.tls.fingerprints == ["abc123"] +def test_client_tokens_before_connecting(): + """Tokens reach the config, and set_tokens before connecting replaces them there.""" + client = moq.connect("https://example.com", tokens=["first", "second"]) + assert client._config.tokens == ["first", "second"] + client.set_tokens(["fresh"]) + assert client._config.tokens == ["fresh"] + + async def test_container_consumer_default_latency_and_context_manager(): """TrackConsumer.subscribe defaults the latency; the returned consumer is also an async context manager.""" diff --git a/rs/moq-ffi/src/session.rs b/rs/moq-ffi/src/session.rs index 137cb781f29..c1d9f989be9 100644 --- a/rs/moq-ffi/src/session.rs +++ b/rs/moq-ffi/src/session.rs @@ -294,9 +294,11 @@ fn parse_tokens(tokens: Vec) -> Result, M tokens .into_iter() .map(|token| { - token.try_into().map_err(|err: moq_tokio::connect::EmptyToken| MoqError::Config { - reason: err.to_string(), - }) + token + .try_into() + .map_err(|err: moq_tokio::connect::EmptyToken| MoqError::Config { + reason: err.to_string(), + }) }) .collect() } diff --git a/rs/moq-net/src/lite/setup.rs b/rs/moq-net/src/lite/setup.rs index 31e00b28bc1..279d6669d08 100644 --- a/rs/moq-net/src/lite/setup.rs +++ b/rs/moq-net/src/lite/setup.rs @@ -589,7 +589,10 @@ mod tests { token: Some(unsendable), ..Default::default() }; - assert!(matches!(msg.encode_bytes(Version::Lite07), Err(EncodeError::Unsupported))); + assert!(matches!( + msg.encode_bytes(Version::Lite07), + Err(EncodeError::Unsupported) + )); } // An empty value carries no credential, so it is refused rather than read as none. @@ -610,7 +613,10 @@ mod tests { let received = |buf: &[u8]| ReceivedSetup::decode_slice(buf, Version::Lite07).unwrap().0; let client = PeerSetup::new(true, crate::transport::Binding::InBand); - assert!(matches!(client.receive(received(&buf)), Err(crate::Error::ProtocolViolation))); + assert!(matches!( + client.receive(received(&buf)), + Err(crate::Error::ProtocolViolation) + )); let server = PeerSetup::new(false, crate::transport::Binding::InBand); server.receive(received(&buf)).unwrap(); } diff --git a/rs/moq-tokio/src/connect/token.rs b/rs/moq-tokio/src/connect/token.rs index c001db8f626..be53ee9ff07 100644 --- a/rs/moq-tokio/src/connect/token.rs +++ b/rs/moq-tokio/src/connect/token.rs @@ -255,7 +255,10 @@ mod tests { let credential = Credential::new(&url, &moq_net::Versions::all(), &tokens(&["a", "peer", "a"])); assert_eq!(credential.url, url); assert_eq!(credential.setup, None); - assert_eq!(credential.extra(&tokens(&["a", "peer", "a"]).tokens), tokens(&["a"]).tokens); + assert_eq!( + credential.extra(&tokens(&["a", "peer", "a"]).tokens), + tokens(&["a"]).tokens + ); } #[test] diff --git a/rs/moq-tokio/src/connection/auth.rs b/rs/moq-tokio/src/connection/auth.rs index 723e90fe246..84c4c52a87f 100644 --- a/rs/moq-tokio/src/connection/auth.rs +++ b/rs/moq-tokio/src/connection/auth.rs @@ -496,7 +496,9 @@ impl Held { let mut answered = false; for (_, answer) in &mut self.tokens { let Answer::Waiting(opening) = answer else { continue }; - let Poll::Ready(result) = opening.as_mut().poll(cx) else { continue }; + let Poll::Ready(result) = opening.as_mut().poll(cx) else { + continue; + }; answered = true; *answer = match result { Ok(auth) => Answer::Granted(auth), @@ -521,7 +523,11 @@ impl Held { /// Withdraw the replaced tokens once nothing is waiting on an answer. fn settle(&mut self) { - if !self.tokens.iter().any(|(_, answer)| matches!(answer, Answer::Waiting(_))) { + if !self + .tokens + .iter() + .any(|(_, answer)| matches!(answer, Answer::Waiting(_))) + { self.retiring.clear(); } } diff --git a/rs/moq-tokio/tests/integration/tokens.rs b/rs/moq-tokio/tests/integration/tokens.rs index c04504adedb..443d7a56cb2 100644 --- a/rs/moq-tokio/tests/integration/tokens.rs +++ b/rs/moq-tokio/tests/integration/tokens.rs @@ -49,7 +49,10 @@ async fn serve(version: &str, refused: &'static str) -> Server { .map(|token| String::from_utf8(token.value.clone()).unwrap()), }; let rejected = seen.token.as_deref() == Some(refused) - || seen.query.as_deref().is_some_and(|query| query.contains(&format!("jwt={refused}"))); + || seen + .query + .as_deref() + .is_some_and(|query| query.contains(&format!("jwt={refused}"))); sender.send(seen).ok(); let notify = notify.clone(); tokio::spawn(async move { @@ -58,7 +61,9 @@ async fn serve(version: &str, refused: &'static str) -> Server { request.reject(moq_tokio::server::Reject::Unauthorized).await.ok(); return; } - let Ok((_session, _driver)) = request.ok().await else { return }; + let Ok((_session, _driver)) = request.ok().await else { + return; + }; // Hold the session until the test's runtime shuts down. std::future::pending::<()>().await; }); diff --git a/swift/Sources/Moq/Client.swift b/swift/Sources/Moq/Client.swift index 4f0bb638118..fa34e949dc8 100644 --- a/swift/Sources/Moq/Client.swift +++ b/swift/Sources/Moq/Client.swift @@ -10,6 +10,11 @@ public final class Client: Sendable { /// - bind: The local UDP address to bind; `nil` binds an ephemeral dual-stack port. /// - versions: Protocol versions to offer, most preferred first (`"moq-lite-03"`); /// empty offers every supported version, which `versions()` lists. + /// - tokens: Credentials every dial presents. The first is the connection credential, + /// sent in SETUP and, while an offered version can't carry it there, the URL's `?jwt=`; + /// each other one widens the session's grant over in-band AUTH. A URL that already + /// carries `?jwt=` keeps it, and then every token rides AUTH. Replace them with + /// `setTokens(_:)`. /// - timeoutUs: How long one connect may take, dial and handshake together; 0 waits forever. /// - raceUs: Happy Eyeballs: how long before the next resolved address is also dialed. /// - resolutionDelayUs: Happy Eyeballs: how long IPv4 waits for the full DNS answer. @@ -27,6 +32,7 @@ public final class Client: Sendable { public init( bind: String? = nil, versions: [String] = [], + tokens: [String] = [], timeoutUs: UInt64? = nil, raceUs: UInt64? = nil, resolutionDelayUs: UInt64? = nil, @@ -41,6 +47,7 @@ public final class Client: Sendable { ffi = try MoqClient(config: MoqClientConfig( bind: bind, versions: versions, + tokens: tokens, timeoutUs: timeoutUs, raceUs: raceUs, resolutionDelayUs: resolutionDelayUs, @@ -59,6 +66,16 @@ public final class Client: Sendable { Session(try await ffi.connect(url: url)) } + /// Replace the credentials every dial presents, such as before the current ones expire. + /// + /// The next dial presents them, and a live session with in-band AUTH presents them at + /// once, withdrawing each replaced token once its replacements are answered. After an + /// authorization refusal the client redials only if they changed. + /// - Throws: `MoqError.Config` for an empty token. + public func setTokens(_ tokens: [String]) throws { + try ffi.setTokens(tokens: tokens) + } + /// Cancel all current and future `connect()` calls. public func cancel() { ffi.cancel() From 6bcbde8eb1916bd58add30eb4ebe5a72402a469c Mon Sep 17 00:00:00 2001 From: Melanie Riise Date: Tue, 20 Oct 2026 20:41:21 -0700 Subject: [PATCH 30/45] feat(js): configured tokens in SETUP, the URL, and AUTH js/net mirrors the Rust client: lite-07's Token setup parameter, the moq-transport AUTHORIZATION TOKEN option on the client's SETUP, a tokens connect option with the same credential rules, and a reloading Connection whose tokens signal redials after a refusal only once they changed and swaps AUTH tokens on a live session. Co-Authored-By: Claude Opus 5.5 --- doc/lib/js/net.md | 5 +- js/net/src/connection/connect.ts | 44 ++++++++- js/net/src/connection/handshake.ts | 4 +- js/net/src/connection/pool.ts | 13 ++- js/net/src/connection/reload.test.ts | 74 ++++++++++++++++ js/net/src/connection/reload.ts | 50 ++++++++++- js/net/src/connection/tokens.test.ts | 102 +++++++++++++++++++++ js/net/src/connection/tokens.ts | 128 +++++++++++++++++++++++++++ js/net/src/lite/connection.ts | 12 ++- js/net/src/lite/setup.test.ts | 23 +++++ js/net/src/lite/setup.ts | 30 +++++-- 11 files changed, 468 insertions(+), 17 deletions(-) create mode 100644 js/net/src/connection/tokens.test.ts create mode 100644 js/net/src/connection/tokens.ts diff --git a/doc/lib/js/net.md b/doc/lib/js/net.md index b0c833901ec..373aa04ab72 100644 --- a/doc/lib/js/net.md +++ b/doc/lib/js/net.md @@ -15,12 +15,15 @@ the types do not spell out. ```ts import * as Moq from "@moq/net"; -const url = new URL("https://cdn.moq.dev/anon?jwt=..."); +const url = new URL("https://cdn.moq.dev/room"); // The origin is the routing table. The broadcast survives a reconnect. const origin = new Moq.Origin.Producer(); const connection = await Moq.Connection.connect({ url, + // The first token rides SETUP (and `?jwt=` for older versions); others widen the grant. + // A reloading `new Moq.Connection({ tokens })` also takes a Signal, set to replace them. + tokens: [token], publish: origin.consume(), consume: origin, }); diff --git a/js/net/src/connection/connect.ts b/js/net/src/connection/connect.ts index 509d8451b2d..101f02f1ec3 100644 --- a/js/net/src/connection/connect.ts +++ b/js/net/src/connection/connect.ts @@ -12,6 +12,7 @@ import type { Established } from "./established.ts"; import * as Extensions from "./extensions.ts"; import { DISCOVERY_REMOVED, forwardAnnounced } from "./forward.ts"; import { exchangeSetup } from "./handshake.ts"; +import { type Credential, credential, hold, withJwt } from "./tokens.ts"; // Default head start for WebTransport before attempting the WebSocket fallback. const DEFAULT_WEBSOCKET_DELAY_MS = Milli(500); @@ -109,6 +110,13 @@ interface Offer { setup: SetupVersion[]; /** Whether the caller named the versions, so the peer must pick one of them. */ explicit: boolean; + /** Whether every offered version carries the connection credential in SETUP. */ + setupToken: boolean; +} + +// moq-lite gained the SETUP token in lite-07; every moq-transport draft has one. +function hasSetupToken(name: string): boolean { + return !name.startsWith("moq-lite-") || name === "moq-lite-07-wip"; } function offer(versions?: readonly string[]): Offer { @@ -121,7 +129,7 @@ function offer(versions?: readonly string[]): Offer { alpns.add(version.alpn); if (version.setup !== undefined) setup.push(version.setup); } - return { alpns: [...alpns], setup, explicit: versions !== undefined }; + return { alpns: [...alpns], setup, explicit: versions !== undefined, setupToken: names.every(hasSetupToken) }; } /** Validate and snapshot an ordered version list before acquiring a connection. @internal */ @@ -202,6 +210,16 @@ export interface ConnectProps { /** The moq-transport extensions to offer, each on unless set to `false`. */ extensions?: Extensions.Extensions; + /** + * Credentials to present, kept apart from the URL. + * + * The first is the connection credential: it rides SETUP, and the URL's `jwt` query too + * while an offered version can't carry it in SETUP (moq-lite before 07). Each other token + * rides its own AUTH stream, widening the session's grant. A URL that already has a `jwt` + * keeps it as the connection credential, and then every token rides AUTH. + */ + tokens?: readonly string[]; + /** * Aborts the connection attempt with the signal's reason. An already-aborted * signal rejects before anything opens, and aborting after the connection is @@ -217,6 +235,8 @@ type SessionProps = { client: boolean; /** The moq-transport extensions this side offers. */ extensions: Extensions.Offered; + /** The connection credential SETUP presents, on versions that carry one. */ + token?: Uint8Array; }; // Save if WebSocket won the last race, so we won't give QUIC a head start next time. @@ -244,12 +264,16 @@ export async function connect({ url, ...props }: ConnectProps): Promise resolve(); signal.addEventListener("abort", onAbort, { once: true }); - const pending = connectInner(url, props, abort); + const tokens = props.tokens ?? []; + const dial = credential(url, tokens, offer(props.versions).setupToken); + const pending = connectInner(dial, props, abort); try { // A `pending` rejection propagates unless the abort beat it to the finish line. const connection = await Promise.race([pending, abort.then(() => undefined)]); if (connection && !signal.aborted) { if (props.consume) forwardAnnounced(connection, props.consume); + // Before the caller sees the session, so its first requests wait on these answers. + hold(connection, dial, tokens); return connection; } @@ -261,12 +285,23 @@ export async function connect({ url, ...props }: ConnectProps): Promise, abort: Promise): Promise { +async function connectInner( + dial: Credential, + props: Omit, + abort: Promise, +): Promise { + const url = dial.url; const wiring: SessionProps = { publish: props.publish, client: true, extensions: Extensions.offered(props.extensions), + token: dial.setup === undefined ? undefined : new TextEncoder().encode(dial.setup), }; + // A fallback URL needs the credential wherever the primary one does. + const fallback = + props.websocket?.url && url.searchParams.get("jwt") && !props.websocket.url.searchParams.get("jwt") + ? withJwt(props.websocket.url, url.searchParams.get("jwt") as string) + : props.websocket?.url; const offered = offer(props.versions); @@ -290,7 +325,7 @@ async function connectInner(url: URL, props: Omit, abort: P !webtransport || websocketWon.has(url.toString()) ? 0 : (props.websocket?.delay ?? DEFAULT_WEBSOCKET_DELAY_MS); const websocket = props.websocket?.enabled !== false - ? connectWebSocket(props.websocket?.url ?? url, headstart, cancel, offered.alpns) + ? connectWebSocket(fallback ?? url, headstart, cancel, offered.alpns) : undefined; if (!websocket && !webtransport) { @@ -433,6 +468,7 @@ async function negotiate( Ietf.hiddenIntoSetup(params); Ietf.maxAgeIntoSetup(params); Ietf.activeCountIntoSetup(params, setupVersion); + if (wiring.token) Ietf.tokenIntoSetup(params, { kind: Ietf.TOKEN_OUT_OF_BAND, value: wiring.token }, setupVersion); const client = new Ietf.ClientSetup({ versions: diff --git a/js/net/src/connection/handshake.ts b/js/net/src/connection/handshake.ts index 1107f2fb915..397ed795f2e 100644 --- a/js/net/src/connection/handshake.ts +++ b/js/net/src/connection/handshake.ts @@ -23,7 +23,7 @@ export async function exchangeSetup( transport: WebTransport, version: Ietf.IetfVersion, implementation: string, - { client, extensions }: { client: boolean; extensions: Extensions.Offered }, + { client, extensions, token }: { client: boolean; extensions: Extensions.Offered; token?: Uint8Array }, ): Promise<{ control: Stream; early: Reader[]; @@ -42,6 +42,8 @@ export async function exchangeSetup( Ietf.hiddenIntoSetup(params); Ietf.maxAgeIntoSetup(params); Ietf.activeCountIntoSetup(params, version); + // Only a client presents a connection credential. + if (client && token) Ietf.tokenIntoSetup(params, { kind: Ietf.TOKEN_OUT_OF_BAND, value: token }, version); // One id per session, like the moq-lite connection: nothing in this process forwards // between sessions, so there is nothing for a shared id to detect. diff --git a/js/net/src/connection/pool.ts b/js/net/src/connection/pool.ts index 66b7dc6ec17..0af3bf808e7 100644 --- a/js/net/src/connection/pool.ts +++ b/js/net/src/connection/pool.ts @@ -76,6 +76,12 @@ export interface ConnectionProps { /** Backoff settings for the reconnect loop; an unset field uses its default. */ delay?: ReloadDelay; + /** + * Credentials to present, kept apart from the URL; see {@link ConnectProps.tokens}. Pass a + * `Signal` to replace them before they expire. Implies a private loop. + */ + tokens?: readonly string[] | Signal; + /** * How to react to the relay's GOAWAY; an unset field uses its default. Every connection * migrates on GOAWAY, dialing the replacement while the old session finishes its groups. @@ -111,7 +117,7 @@ export interface ConnectionProps { * session finishes its groups, and the handle and its origin carry across. A redirect the * {@link ConnectionProps.goaway} policy refuses stops the loop like an auth rejection does. * - * Options the pool cannot honor (transport options, delay, goaway, a pinned + * Options the pool cannot honor (transport options, delay, goaway, tokens, a pinned * certificate, caller-owned origins) use a private loop. An explicit `share: true` * refuses them. A supplied transport cannot reconnect at all; pass it to {@link Connection.connect} * instead. @@ -264,6 +270,7 @@ export class Connection { websocket: props.websocket, delay: props.delay, goaway: props.goaway, + tokens: props.tokens, }); this.#signals.cleanup(() => loop.close()); @@ -387,6 +394,9 @@ function refuse(props?: ConnectionProps): void { if (props.delay !== undefined) { throw new Error("delay cannot be shared; pass share: false"); } + if (props.tokens !== undefined) { + throw new Error("tokens cannot be shared; pass share: false"); + } if (props.goaway !== undefined) { throw new Error("goaway cannot be shared; pass share: false"); } @@ -398,6 +408,7 @@ function requiresPrivate(props: ConnectionProps): boolean { props.webtransport !== undefined || props.websocket !== undefined || props.delay !== undefined || + props.tokens !== undefined || props.goaway !== undefined || props.publish !== undefined || props.consume !== undefined diff --git a/js/net/src/connection/reload.test.ts b/js/net/src/connection/reload.test.ts index 65f6a3bf4a9..e598cef8290 100644 --- a/js/net/src/connection/reload.test.ts +++ b/js/net/src/connection/reload.test.ts @@ -410,6 +410,80 @@ for (const code of [SessionCode.Unauthorized, SessionCode.Expired]) { }); } +/** + * Stub WebTransport for lite-06, which carries the credential in the URL: refuses `refused` + * once `release` resolves, and accepts any other credential, recording each dialed jwt. + */ +function refusing(refused: string, release: Promise) { + const dialed: (string | null)[] = []; + const stub = function StubWebTransport(target: string | URL) { + const jwt = new URL(target).searchParams.get("jwt"); + dialed.push(jwt); + const pair = createMockTransportPair(Lite.ALPN_06); + void accept(pair.server, new URL(target)).then(async () => { + if (jwt !== refused) return; + await release; + pair.server.close({ closeCode: SessionCode.Unauthorized, reason: "unauthorized" }); + }); + return pair.client; + }; + return { dialed, stub: stub as unknown as typeof WebTransport }; +} + +test("a refused credential replaced since its dial redials with the new one", async () => { + const original = globalThis.WebTransport; + const { promise: release, resolve } = Promise.withResolvers(); + const { dialed, stub } = refusing("old", release); + globalThis.WebTransport = stub; + + const tokens = new Signal(["old"]); + const reload = new Reload({ + url: new URL("https://example.com/"), + versions: ["moq-lite-06"], + websocket: { enabled: false }, + tokens, + delay: { initial: Milli(1), multiplier: 2, max: Milli(1), timeout: Milli(0) }, + }); + try { + await waitUntil(() => reload.established.peek() !== undefined); + tokens.set(["new"]); + resolve(); + await waitUntil(() => dialed.includes("new")); + expect(dialed).toEqual(["old", "new"]); + await waitUntil(() => reload.established.peek() !== undefined); + expect(reload.error.peek()).toBeUndefined(); + } finally { + reload.close(); + globalThis.WebTransport = original; + } +}); + +test("new tokens redial a URL that stopped on refusing the old ones", async () => { + const original = globalThis.WebTransport; + const { dialed, stub } = refusing("old", Promise.resolve()); + globalThis.WebTransport = stub; + + const tokens = new Signal(["old"]); + const reload = new Reload({ + url: new URL("https://example.com/"), + versions: ["moq-lite-06"], + websocket: { enabled: false }, + tokens, + delay: { initial: Milli(1), multiplier: 2, max: Milli(1), timeout: Milli(0) }, + }); + try { + await waitUntil(() => reload.error.peek() !== undefined); + expect(dialed).toEqual(["old"]); + tokens.set(["new"]); + await waitUntil(() => dialed.includes("new")); + await waitUntil(() => reload.established.peek() !== undefined); + expect(reload.error.peek()).toBeUndefined(); + } finally { + reload.close(); + globalThis.WebTransport = original; + } +}); + test("an unauthorized session close during setup stops retrying", async () => { const original = globalThis.WebTransport; const url = new URL("https://example.com/"); diff --git a/js/net/src/connection/reload.ts b/js/net/src/connection/reload.ts index 700c8e69cbc..74b7ac9d726 100644 --- a/js/net/src/connection/reload.ts +++ b/js/net/src/connection/reload.ts @@ -18,6 +18,11 @@ import type { Established } from "./established.ts"; import { DISCOVERY_REMOVED } from "./forward.ts"; import { DEFAULT_HANDOVER, type Drain, dialed, type GoawayProps, handover, pinnedTransport, target } from "./goaway.ts"; import type { Probe, Stats } from "./stats.ts"; +import { heldOf } from "./tokens.ts"; + +function sameTokens(a: readonly string[], b: readonly string[]): boolean { + return a.length === b.length && a.every((token, i) => token === b[i]); +} /** * Exponential backoff settings for the reconnect loop. @@ -58,7 +63,10 @@ export type ReloadDelay = { * * @internal */ -export type ReloadProps = Omit & { +export type ReloadProps = Omit & { + /** See {@link ConnectProps.tokens}; pass a `Signal` to replace them before they expire. */ + tokens?: readonly string[] | Signal; + /** A reload owns the abort signal for each connection attempt. */ signal?: never; @@ -113,6 +121,14 @@ export class Reload { /** Whether reconnecting is active. */ enabled: Signal; + /** + * Credentials every dial presents; see {@link ConnectProps.tokens}. Replacing them reaches + * the next dial and, on a live session with AUTH, presents the new tokens at once, + * withdrawing the replaced ones once every new one has its answer. A session refused as + * unauthorized redials only when these changed since its dial. + */ + tokens: Signal; + /** Current connection status. */ status = new Signal("disconnected"); @@ -235,6 +251,11 @@ export class Reload { // loop is disabled. #givenUpHref: string | undefined; + // The tokens the latest dial presented, so a refusal can tell whether they changed since. + #dialedTokens: readonly string[] = []; + // Whether the sequence stopped because the peer refused those tokens. + #refused = false; + // Increased by 1 each time to trigger a reload. #tick = new Signal(0); @@ -248,6 +269,7 @@ export class Reload { if (props && "discovery" in props) throw new TypeError(DISCOVERY_REMOVED); this.url = Signal.from(props?.url); this.enabled = Signal.from(props?.enabled ?? true); + this.tokens = Signal.from(props?.tokens ?? []); this.delay = props?.delay ?? {}; this.goaway = props?.goaway ?? {}; this.redirect = this.#redirect; @@ -326,6 +348,23 @@ export class Reload { effect.interval(() => void sample(), BANDWIDTH_POLL); }); + // The live session holds the current tokens on AUTH; a dial picks them up itself. + this.#signals.run((effect) => { + const tokens = effect.get(this.tokens); + const connection = effect.get(this.established); + if (connection) heldOf(connection)?.update(tokens); + }); + + // New credentials redial a URL that stopped on an auth refusal of the old ones. + this.#signals.run((effect) => { + const tokens = effect.get(this.tokens); + if (!this.#refused || this.#givenUpHref === undefined || sameTokens(tokens, this.#dialedTokens)) return; + this.#refused = false; + this.#givenUpHref = undefined; + this.#error.set(undefined); + this.#tick.update((tick) => tick + 1); + }); + this.#url = this.#signals.computed((effect) => effect.get(this.url)?.href); // Create a reactive root so cleanup is easier. this.#signals.run(this.#connect.bind(this)); @@ -403,10 +442,12 @@ export class Reload { const dialing = this.#redirect.peek() ?? url; this.#dialing = true; + this.#dialedTokens = this.tokens.peek(); let connection: Established; try { connection = await connect({ url: dialing, + tokens: this.#dialedTokens, // A redirect names the relay; a fallback URL pinned for the old one does not follow. websocket: this.#redirect.peek() ? { ...this.websocket, url: undefined } : this.websocket, versions: this.versions, @@ -582,12 +623,16 @@ export class Reload { // Only a session close says that. The stream registry gives 2 to DELIVERY_TIMEOUT, // so a stream reset during the SETUP exchange would otherwise suppress reconnect // for good. + // + // Tokens replaced since that dial are new credentials, so those earn another dial. if ( cause instanceof SessionError && - (cause.code === SessionCode.Unauthorized || cause.code === SessionCode.Expired) + (cause.code === SessionCode.Unauthorized || cause.code === SessionCode.Expired) && + sameTokens(this.tokens.peek(), this.#dialedTokens) ) { console.warn("session rejected as unauthorized, not retrying"); this.#giveUp(cause); + this.#refused = true; return; } @@ -612,6 +657,7 @@ export class Reload { } #giveUp(cause: Error): void { + this.#refused = false; this.#error.set(cause); this.#givenUpHref = this.#sequenceHref; this.#resetSequence(); diff --git a/js/net/src/connection/tokens.test.ts b/js/net/src/connection/tokens.test.ts new file mode 100644 index 00000000000..a972f91b6c7 --- /dev/null +++ b/js/net/src/connection/tokens.test.ts @@ -0,0 +1,102 @@ +import { expect, test } from "bun:test"; +import type { Auth, Handle } from "../auth.ts"; +import * as Ietf from "../ietf/index.ts"; +import { createMockTransportPair } from "../mock.ts"; +import { Reader } from "../stream.ts"; +import { offered } from "./extensions.ts"; +import { exchangeSetup } from "./handshake.ts"; +import { credential, extra, Held } from "./tokens.ts"; + +const URL_ = new URL("https://relay.example.com/room"); + +test("versions with a SETUP token keep the credential out of the URL", () => { + const dial = credential(URL_, ["a", "b"], true); + expect(dial.url.href).toBe(URL_.href); + expect(dial.setup).toBe("a"); + expect(extra(dial, ["a", "b", "b"])).toEqual(["b"]); +}); + +test("an older offered version puts the credential in the URL too", () => { + const dial = credential(new URL("https://relay.example.com/room?x=1"), ["a b"], false); + expect(dial.url.href).toBe("https://relay.example.com/room?x=1&jwt=a+b"); + expect(dial.setup).toBe("a b"); + expect(extra(dial, ["a b"])).toEqual([]); +}); + +test("a URL credential sends every other token on AUTH", () => { + const url = new URL("https://relay.example.com/room?jwt=peer"); + const dial = credential(url, ["a", "peer"], false); + expect(dial.url.href).toBe(url.href); + expect(dial.setup).toBeUndefined(); + expect(extra(dial, ["a", "peer"])).toEqual(["a"]); +}); + +test("no tokens present nothing", () => { + const dial = credential(URL_, [], false); + expect(dial.url.href).toBe(URL_.href); + expect(dial.setup).toBeUndefined(); +}); + +/** A handle whose answers the test releases one token at a time. */ +function handle() { + const answers = new Map void>(); + const closed: string[] = []; + const fake = { + add(token: string) { + return new Promise((resolve) => answers.set(token, resolve)); + }, + } as unknown as Handle; + const answer = async (token: string) => { + const resolve = answers.get(token); + if (!resolve) throw new Error(`${token} was never presented`); + resolve({ close: () => closed.push(token) } as unknown as Auth); + // Let the answer and the settle it schedules run. + for (let i = 0; i < 3; i++) await Promise.resolve(); + }; + return { fake, answers, closed, answer }; +} + +test("a replaced token is withdrawn only once its replacement answers", async () => { + const { fake, answers, closed, answer } = handle(); + const held = new Held(fake, credential(URL_, ["connection"], true)); + held.update(["connection", "old"]); + expect([...answers.keys()]).toEqual(["old"]); + await answer("old"); + + held.update(["connection", "new"]); + expect([...answers.keys()]).toEqual(["old", "new"]); + expect(closed).toEqual([]); + + await answer("new"); + expect(closed).toEqual(["old"]); +}); + +test("a token replaced before its answer closes when it arrives", async () => { + const { fake, closed, answer } = handle(); + const held = new Held(fake, credential(URL_, ["connection"], true)); + held.update(["connection", "old"]); + held.update(["connection", "new"]); + await answer("new"); + expect(closed).toEqual([]); + await answer("old"); + expect(closed).toEqual(["old"]); +}); + +test("a draft-17+ client presents its token in SETUP", async () => { + const version = Ietf.Version.DRAFT_18; + const pair = createMockTransportPair(Ietf.ALPN.DRAFT_18); + const token = new TextEncoder().encode("credential"); + // Never answered: only the SETUP the client writes matters here. + void exchangeSetup(pair.client, version, "test", { client: true, extensions: offered(), token }).catch(() => {}); + + const streams = pair.server.incomingUnidirectionalStreams.getReader(); + const next = await streams.read(); + if (next.done) throw new Error("no SETUP stream"); + const reader = new Reader(next.value, undefined, version); + expect(await reader.u53()).toBe(Ietf.Setup.id); + const setup = await Ietf.Setup.decode(reader, version); + const presented = Ietf.tokenFromSetup(setup.parameters, version); + expect(presented?.kind).toBe(Ietf.TOKEN_OUT_OF_BAND); + expect(new TextDecoder().decode(presented?.value)).toBe("credential"); + pair.client.close(); +}); diff --git a/js/net/src/connection/tokens.ts b/js/net/src/connection/tokens.ts new file mode 100644 index 00000000000..97d72f7bac7 --- /dev/null +++ b/js/net/src/connection/tokens.ts @@ -0,0 +1,128 @@ +import { type Auth, type Handle, Unsupported } from "../auth.ts"; +import type { Established } from "./established.ts"; + +/** What one dial presents, decided from its URL, the offered versions, and the tokens. @internal */ +export interface Credential { + /** The URL to dial, carrying the connection credential when an offered version has no SETUP token. */ + url: URL; + /** The token SETUP carries, unless the URL brought its own credential. */ + setup?: string; + /** The credential the connection presents, from the URL or SETUP. Never also presented on AUTH. */ + connection?: string; +} + +/** + * Decide what one dial presents. + * + * A URL that already carries a `jwt` keeps it (a per-peer credential), so every token rides + * AUTH. Otherwise the first token is the connection credential: it rides SETUP, and the URL + * too when `setupToken` is false, since the URL is fixed before the version is negotiated. + * + * @internal + */ +export function credential(url: URL, tokens: readonly string[], setupToken: boolean): Credential { + const jwt = url.searchParams.get("jwt"); + if (jwt) return { url, connection: jwt }; + const first = tokens[0]; + if (first === undefined) return { url }; + return { url: setupToken ? url : withJwt(url, first), setup: first, connection: first }; +} + +/** `url` with `jwt` appended to its query. @internal */ +export function withJwt(url: URL, jwt: string): URL { + const next = new URL(url); + next.searchParams.append("jwt", jwt); + return next; +} + +/** The tokens to hold on their own AUTH streams, each once. @internal */ +export function extra(credential: Credential, tokens: readonly string[]): string[] { + return [...new Set(tokens)].filter((token) => token !== credential.connection); +} + +interface Entry { + auth?: Auth; + pending: boolean; + retired: boolean; +} + +/** + * The configured tokens one session holds on their own AUTH streams. + * + * A change presents the new tokens at once and withdraws the ones they replace only after + * every new one has its answer, so the grant never narrows in between. + * + * @internal + */ +export class Held { + #handle: Handle; + #credential: Credential; + #entries = new Map(); + #retiring: Entry[] = []; + + constructor(handle: Handle, credential: Credential) { + this.#handle = handle; + this.#credential = credential; + } + + /** Hold exactly `tokens`, other than the connection credential. */ + update(tokens: readonly string[]) { + const next = new Map(); + for (const token of extra(this.#credential, tokens)) { + next.set(token, this.#entries.get(token) ?? this.#present(token)); + } + for (const [token, entry] of this.#entries) { + if (!next.has(token)) this.#retiring.push(entry); + } + this.#entries = next; + this.#settle(); + } + + #present(token: string): Entry { + const entry: Entry = { pending: true, retired: false }; + // Registered synchronously, so a request sent right after waits on its answer. + this.#handle + .add(token) + .then( + (auth) => { + entry.auth = auth; + // Replaced while waiting, and every replacement has answered since. + if (entry.retired) auth.close(); + }, + (err: unknown) => { + if (err instanceof Unsupported) console.debug("the peer takes no tokens in band"); + else console.warn("a configured token was refused:", err); + }, + ) + .finally(() => { + entry.pending = false; + this.#settle(); + }); + return entry; + } + + #settle() { + if ([...this.#entries.values()].some((entry) => entry.pending)) return; + for (const entry of this.#retiring) { + entry.retired = true; + entry.auth?.close(); + } + this.#retiring = []; + } +} + +// The tokens each connection holds, so a reconnecting wrapper can update them. +const held = new WeakMap(); + +/** Present `tokens` on `connection`, beside the credential its dial carried. @internal */ +export function hold(connection: Established, credential: Credential, tokens: readonly string[]): Held { + const tokensHeld = new Held(connection.auth, credential); + tokensHeld.update(tokens); + held.set(connection, tokensHeld); + return tokensHeld; +} + +/** The tokens `connection` holds, if it was dialed with any. @internal */ +export function heldOf(connection: Established): Held | undefined { + return held.get(connection); +} diff --git a/js/net/src/lite/connection.ts b/js/net/src/lite/connection.ts index 86dc40c6bc3..535bc03b860 100644 --- a/js/net/src/lite/connection.ts +++ b/js/net/src/lite/connection.ts @@ -61,6 +61,8 @@ export interface ConnectionProps { * Defaults to true. */ client?: boolean; + /** The connection credential the client's SETUP presents, on versions that carry one (lite-07+). */ + token?: Uint8Array; } /** @@ -119,6 +121,8 @@ export class Connection implements Established { // Whether we dialed; see {@link ConnectionProps.client}. #client: boolean; + // See {@link ConnectionProps.token}. + #token?: Uint8Array; // The peer's GOAWAY. Lite carries no deadline, so only the URI is set. #goaway = new Once(); @@ -140,8 +144,10 @@ export class Connection implements Established { * * @internal */ - constructor({ url, quic, version, session, publish, client = true }: ConnectionProps) { + constructor({ url, quic, version, session, publish, client = true, token }: ConnectionProps) { this.url = url; + // Older versions carry the credential in the URL instead. + this.#token = client && hasAuth(version) ? token : undefined; this.#quic = quic; this.#session = session; // The session stream was opened at the SETUP exchange's version; the rest of it speaks this one. @@ -302,7 +308,7 @@ export class Connection implements Established { try { await writer.u53(DataType.Setup); const probe = await probeLevel(this.#quic, this.#version); - await new Setup({ probe, hop: this.hop }).encode(writer, this.#version); + await new Setup({ probe, hop: this.hop, token: this.#token }).encode(writer, this.#version); writer.close(); } catch (err: unknown) { writer.reset(err); @@ -416,7 +422,7 @@ export class Connection implements Established { throw new ProtocolViolation("invalid SETUP", { cause: err }); }); if (this.#client && hasClientParameters(setup)) - throw new ProtocolViolation("server SETUP contains Path or Role"); + throw new ProtocolViolation("server SETUP contains Path, Role, or Token"); if (!this.#client && setup.path !== undefined) throw new ProtocolViolation("Path on a URI-carrying binding"); this.#peerSetup.set(setup); diff --git a/js/net/src/lite/setup.test.ts b/js/net/src/lite/setup.test.ts index 3b26a8b76ca..39762ccb1a6 100644 --- a/js/net/src/lite/setup.test.ts +++ b/js/net/src/lite/setup.test.ts @@ -183,3 +183,26 @@ test("SETUP decode refuses an oversized length before reading the body", async ( "too large", ); }); + +const PARAM_TOKEN = 0x6; + +test("SETUP token round-trips on lite-07 only", async () => { + const msg = new Setup({ token: new TextEncoder().encode("jwt") }); + const wire = await bytes((w) => msg.encode(w, Version.DRAFT_07), Version.DRAFT_07); + // Length, one parameter, its id, then the token's length and bytes. + expect([...wire]).toEqual([0x06, 0x01, PARAM_TOKEN, 0x03, 0x6a, 0x77, 0x74]); + const got = await Setup.decode(new Reader(undefined, wire, Version.DRAFT_07), Version.DRAFT_07); + expect(new TextDecoder().decode(got.token)).toBe("jwt"); + + await expect(bytes((w) => msg.encode(w, Version.DRAFT_06), Version.DRAFT_06)).rejects.toThrow(); + // An older version ignores the parameter like any unknown one. + const older = await Setup.decode(new Reader(undefined, wire, Version.DRAFT_06), Version.DRAFT_06); + expect(older.token).toBeUndefined(); +}); + +test("SETUP refuses an empty token", async () => { + const empty = new Setup({ token: new Uint8Array() }); + await expect(bytes((w) => empty.encode(w, Version.DRAFT_07), Version.DRAFT_07)).rejects.toThrow(); + const wire = new Uint8Array([0x03, 0x01, PARAM_TOKEN, 0x00]); + await expect(Setup.decode(new Reader(undefined, wire, Version.DRAFT_07), Version.DRAFT_07)).rejects.toThrow(); +}); diff --git a/js/net/src/lite/setup.ts b/js/net/src/lite/setup.ts index 1cea1263d67..ada0bfa5b68 100644 --- a/js/net/src/lite/setup.ts +++ b/js/net/src/lite/setup.ts @@ -9,11 +9,11 @@ import { type Hop, HopSchema } from "../hop.ts"; import { encodeVarint, Reader, type Writer } from "../stream.ts"; import { decodeUtf8 } from "../util/utf8.ts"; import * as Message from "./message.ts"; -import { hasSetupStream, type Version } from "./version.ts"; +import { hasAuth, hasSetupStream, type Version } from "./version.ts"; const clientParameters = new WeakSet(); -/** Whether the peer's SETUP carried Path or Role; raw presence matters even when an unknown Role decodes as Both. */ +/** Whether the peer's SETUP carried Path, Role, or Token; raw presence matters even when an unknown Role decodes as Both. */ export function hasClientParameters(setup: Setup): boolean { return clientParameters.has(setup); } @@ -26,6 +26,8 @@ const PARAM_PATH = 0x2n; const PARAM_ROLE = 0x3n; /** Setup Parameter id for the endpoint's Hop ID. */ const PARAM_HOP = 0x5n; +/** Setup Parameter id for the client's connection credential (client-only, lite-07+). */ +const PARAM_TOKEN = 0x6n; /** Cap on the number of parameters in a bag, matching the Rust decoder. */ const MAX_PARAMS = 64; @@ -182,6 +184,8 @@ export interface SetupProps { role?: Role; /** See {@link Setup.hop}. Omitted by default. */ hop?: Hop; + /** See {@link Setup.token}. Omitted by default. */ + token?: Uint8Array; } /** @@ -219,11 +223,18 @@ export class Setup { */ hop?: Hop; - constructor({ probe, path, role, hop }: SetupProps = {}) { + /** + * The connection credential (lite-07+), which the empty-token Auth Stream names. Sent + * only by the client. Never empty: an empty value is no credential. + */ + token?: Uint8Array; + + constructor({ probe, path, role, hop, token }: SetupProps = {}) { this.probe = probe ?? ProbeLevel.None; this.path = path; this.role = role ?? Role.Both; this.hop = hop; + this.token = token; } static #guard(version: Version) { @@ -248,6 +259,11 @@ export class Setup { if (this.hop !== undefined && this.hop !== 0n) { params.setVarint(PARAM_HOP, this.hop, version); } + if (this.token !== undefined) { + if (!hasAuth(version)) throw new Error("setup token not supported for this version"); + if (this.token.byteLength === 0) throw new Error("setup token cannot be empty"); + params.setBytes(PARAM_TOKEN, this.token); + } await params.encode(w); } @@ -269,8 +285,12 @@ export class Setup { const hopRaw = await params.getVarint(PARAM_HOP, version); const hop = hopRaw === undefined || hopRaw === 0n ? undefined : HopSchema.parse(hopRaw); - const setup = new Setup({ probe, path, role, hop }); - if (pathBytes !== undefined || roleCode !== undefined) clientParameters.add(setup); + // An older version does not know the parameter, so it ignores it like any other. + const token = hasAuth(version) ? params.getBytes(PARAM_TOKEN) : undefined; + if (token?.byteLength === 0) throw new Error("setup token cannot be empty"); + + const setup = new Setup({ probe, path, role, hop, token }); + if (pathBytes !== undefined || roleCode !== undefined || token !== undefined) clientParameters.add(setup); return setup; } From 451abaf062eaf4e92a12d14bd81de7ed23d165d3 Mon Sep 17 00:00:00 2001 From: Melanie Riise Date: Wed, 21 Oct 2026 01:16:33 -0700 Subject: [PATCH 31/45] fix(tokio): count a configured token a session cannot send as unsupported On moq-transport without the Auth extension the session accepts a token at once for the requests that name it, but a configured token rides no request. Treat it as unsupported instead of granted, and log that once per session. Co-Authored-By: Claude Opus 5.5 --- rs/moq-tokio/src/connection/auth.rs | 37 +++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/rs/moq-tokio/src/connection/auth.rs b/rs/moq-tokio/src/connection/auth.rs index 84c4c52a87f..856b3cd64c2 100644 --- a/rs/moq-tokio/src/connection/auth.rs +++ b/rs/moq-tokio/src/connection/auth.rs @@ -454,6 +454,8 @@ struct Held { /// Tokens a change replaced, answered or not, kept presented until every /// replacement has its answer so the grant never narrows in between. retiring: Vec, + /// Whether this session already logged that it carries no extra tokens. + unsupported: bool, } enum Answer { @@ -494,6 +496,7 @@ impl Held { /// Resolve when another token has its answer. fn poll_answers(&mut self, session: &moq_net::Session, cx: &mut std::task::Context<'_>) -> Poll<()> { let mut answered = false; + let mut unsupported = false; for (_, answer) in &mut self.tokens { let Answer::Waiting(opening) = answer else { continue }; let Poll::Ready(result) = opening.as_mut().poll(cx) else { @@ -501,11 +504,17 @@ impl Held { }; answered = true; *answer = match result { + // Without AUTH, moq-transport accepts a token at once for the requests that name + // it, but a configured token rides no request, so nothing sends it. + Ok(auth) if auth.grant().peek().is_none() && auth.closed().now_or_never().is_none() => { + unsupported = true; + Answer::Refused + } Ok(auth) => Answer::Granted(auth), // The session ending is no verdict on the token. Err(_) if session.closed().now_or_never().is_some() => Answer::Refused, Err(moq_net::Error::Unsupported) => { - tracing::debug!("the peer takes no tokens in band; presenting only the connection credential"); + unsupported = true; Answer::Refused } Err(err) => { @@ -514,6 +523,9 @@ impl Held { } }; } + if unsupported && !std::mem::replace(&mut self.unsupported, true) { + tracing::debug!("the peer takes no tokens in band; presenting only the connection credential"); + } self.settle(); match answered { true => Poll::Ready(()), @@ -545,7 +557,11 @@ mod tests { } async fn pair() -> Pair { - let versions = moq_net::Versions::from("moq-lite-07-wip".parse::().unwrap()); + pair_on("moq-lite-07-wip").await + } + + async fn pair_on(version: &str) -> Pair { + let versions = moq_net::Versions::from(version.parse::().unwrap()); let config = || { let mut config = qmux::Config::new(qmux::Version::QMux01); config.protocol = qmux::Protocol::Negotiate(versions.alpns().iter().map(|alpn| alpn.to_string()).collect()); @@ -899,6 +915,23 @@ mod tests { assert!(again.is_err(), "the connection credential was presented twice"); } + /// Without AUTH a configured token rides nothing, so it counts as unsupported rather + /// than granted, and the session logs that once. + #[tokio::test(start_paused = true)] + async fn a_configured_token_without_auth_is_unsupported() { + let pair = pair_on("moq-transport-14").await; + let mut held = Held::default(); + held.update(&pair.client.auth(), vec!["a".parse().unwrap(), "b".parse().unwrap()]); + tokio::time::timeout( + std::time::Duration::from_secs(5), + std::future::poll_fn(|cx| held.poll_answers(&pair.client, cx)), + ) + .await + .expect("no answer"); + assert!(held.tokens.iter().all(|(_, answer)| matches!(answer, Answer::Refused))); + assert!(held.unsupported); + } + /// A replaced token is withdrawn only once its replacement has an answer, so the grant /// never narrows in between. #[tokio::test(start_paused = true)] From b81dd038d3654cea6d7947b4683636994a27e302 Mon Sep 17 00:00:00 2001 From: Melanie Riise Date: Wed, 21 Oct 2026 01:16:33 -0700 Subject: [PATCH 32/45] docs(ffi): migrate MoqClientConfig.tokens Co-Authored-By: Claude Opus 5.5 --- rs/moq-ffi/MIGRATE.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/rs/moq-ffi/MIGRATE.md b/rs/moq-ffi/MIGRATE.md index d12b0488b5f..491b5cd371b 100644 --- a/rs/moq-ffi/MIGRATE.md +++ b/rs/moq-ffi/MIGRATE.md @@ -5,6 +5,10 @@ These are the UniFFI names; each language wrapper has its own `MIGRATE.md` in it ## Unreleased +### `MoqClientConfig` gains `tokens` + +It defaults to empty in every generated binding; a Rust struct literal adds `tokens: vec![]` or ends with `..Default::default()`. + ### `MoqTrackInfo` gains `compress` (#5928) It defaults to `false` in every generated binding; a Rust struct literal adds `compress: false`. From 7dd7169f49f3d648dc5a7d638d8478d5d2467cd7 Mon Sep 17 00:00:00 2001 From: Melanie Riise Date: Wed, 21 Oct 2026 01:25:41 -0700 Subject: [PATCH 33/45] chore(dart): regenerate moq_ffi for configured tokens Co-Authored-By: Claude Opus 5.5 --- dart/moq_ffi/lib/src/moq.dart | 40 +++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/dart/moq_ffi/lib/src/moq.dart b/dart/moq_ffi/lib/src/moq.dart index 8e584b4f4e1..91c15229cff 100644 --- a/dart/moq_ffi/lib/src/moq.dart +++ b/dart/moq_ffi/lib/src/moq.dart @@ -3103,6 +3103,7 @@ class FfiConverterMoqBackoff { class MoqClientConfig { final String? bind; final List versions; + final List tokens; final int? timeoutUs; final int? raceUs; final int? resolutionDelayUs; @@ -3116,6 +3117,7 @@ class MoqClientConfig { MoqClientConfig({ this.bind = null, this.versions = const [], + this.tokens = const [], this.timeoutUs = null, this.raceUs = null, this.resolutionDelayUs = null, @@ -3146,6 +3148,11 @@ class FfiConverterMoqClientConfig { ); final versions = versions_lifted.value; new_offset += versions_lifted.bytesRead; + final tokens_lifted = FfiConverterSequenceString.read( + Uint8List.view(buf.buffer, new_offset), + ); + final tokens = tokens_lifted.value; + new_offset += tokens_lifted.bytesRead; final timeoutUs_lifted = FfiConverterOptionalUInt64.read( Uint8List.view(buf.buffer, new_offset), ); @@ -3200,6 +3207,7 @@ class FfiConverterMoqClientConfig { MoqClientConfig( bind: bind, versions: versions, + tokens: tokens, timeoutUs: timeoutUs, raceUs: raceUs, resolutionDelayUs: resolutionDelayUs, @@ -3219,6 +3227,7 @@ class FfiConverterMoqClientConfig { final total_length = FfiConverterOptionalString.allocationSize(value.bind) + FfiConverterSequenceString.allocationSize(value.versions) + + FfiConverterSequenceString.allocationSize(value.tokens) + FfiConverterOptionalUInt64.allocationSize(value.timeoutUs) + FfiConverterOptionalUInt64.allocationSize(value.raceUs) + FfiConverterOptionalUInt64.allocationSize(value.resolutionDelayUs) + @@ -3245,6 +3254,10 @@ class FfiConverterMoqClientConfig { value.versions, Uint8List.view(buf.buffer, new_offset), ); + new_offset += FfiConverterSequenceString.write( + value.tokens, + Uint8List.view(buf.buffer, new_offset), + ); new_offset += FfiConverterOptionalUInt64.write( value.timeoutUs, Uint8List.view(buf.buffer, new_offset), @@ -3291,6 +3304,7 @@ class FfiConverterMoqClientConfig { static int allocationSize(MoqClientConfig value) { return FfiConverterOptionalString.allocationSize(value.bind) + FfiConverterSequenceString.allocationSize(value.versions) + + FfiConverterSequenceString.allocationSize(value.tokens) + FfiConverterOptionalUInt64.allocationSize(value.timeoutUs) + FfiConverterOptionalUInt64.allocationSize(value.raceUs) + FfiConverterOptionalUInt64.allocationSize(value.resolutionDelayUs) + @@ -12223,6 +12237,7 @@ abstract class MoqClientInterface { void cancel(); Future connect({required String url}); MoqException? failure(); + void setTokens({required List tokens}); } final _MoqClientFinalizer = Finalizer>((ptr) { @@ -12296,6 +12311,16 @@ class MoqClient implements MoqClientInterface { null, ); } + + void setTokens({required List tokens}) { + return rustCall((status) { + uniffi_moq_ffi_fn_method_moqclient_set_tokens( + uniffiClonePointer(), + FfiConverterSequenceString.lower(tokens), + status, + ); + }, moqExceptionErrorHandler); + } } class FfiConverterMoqClient { @@ -17555,6 +17580,15 @@ external RustBuffer uniffi_moq_ffi_fn_method_moqclient_failure( Pointer uniffiStatus, ); +@Native, RustBuffer, Pointer)>( + assetId: _uniffiAssetId, +) +external void uniffi_moq_ffi_fn_method_moqclient_set_tokens( + Pointer ptr, + RustBuffer tokens, + Pointer uniffiStatus, +); + @Native Function(Pointer, Pointer)>( assetId: _uniffiAssetId, ) @@ -18754,6 +18788,9 @@ external int uniffi_moq_ffi_checksum_method_moqclient_connect(); @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqclient_failure(); +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqclient_set_tokens(); + @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqsession_auth(); @@ -19446,6 +19483,9 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_method_moqclient_failure() != 29341) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } + if (uniffi_moq_ffi_checksum_method_moqclient_set_tokens() != 4148) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } if (uniffi_moq_ffi_checksum_method_moqsession_auth() != 1680) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } From 3ed59324f68b9cd2f6c35ee01a778fdb52bffd86 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 12:50:53 -0700 Subject: [PATCH 34/45] fix(auth): a draining session takes no new tokens A token added after a GOAWAY was offered to the draining session too: a draining moq-lite session without AUTH refused it as Unsupported, which ended it on the successor, and in JS a draining session opened a fresh AUTH stream for it. Offer new tokens only to active sessions, and in JS stop a transient retry once the session drains. JS also points the handle's grants at the successor as soon as it attaches. Co-Authored-By: Claude Opus 5.5 --- js/net/src/auth.test.ts | 41 +++++++++++++++++++++++++++++++++++++- js/net/src/auth_session.ts | 15 ++++++++++++-- rs/moq-net/src/auth.rs | 23 ++++++++++++++++++++- 3 files changed, 75 insertions(+), 4 deletions(-) diff --git a/js/net/src/auth.test.ts b/js/net/src/auth.test.ts index ae5295e9e05..d5e3549b965 100644 --- a/js/net/src/auth.test.ts +++ b/js/net/src/auth.test.ts @@ -15,7 +15,7 @@ import * as LiteMessage from "./lite/message.ts"; import { createMockTransportPair, type MockTransport } from "./mock.ts"; import { Producer as OriginProducer } from "./origin.ts"; import * as Path from "./path.ts"; -import { type Stream, Writer } from "./stream.ts"; +import { Stream, Writer } from "./stream.ts"; import { abandoned } from "./util/demand.ts"; import { withTimeout } from "./util/timeout.ts"; import { nextTask } from "./util/yield.ts"; @@ -715,6 +715,45 @@ test("lite-07 presents a transiently refused token again after a backoff", async } }); +test("lite-07 presents a token added after a GOAWAY only on the successor", async () => { + const protocol = Lite.ALPN_07_WIP; + const handle = new Handle(); + const pair = createMockTransportPair(protocol); + const [client, server] = await Promise.all([ + connectSession({ url, transport: pair.client, publish: new OriginProducer().consume(), auth: handle }), + acceptSession({ transport: pair.server, url }), + ]); + const seen: string[] = []; + answer(server.auth.requests(), (token) => { + seen.push(token); + return token ? grant(["first"], []) : grant([], []); + }); + const token = await handle.add("token"); + await waitFor(handle.grant, (g) => g?.publish.equals(patterns("first")) === true); + + const stream = await Stream.open(pair.server, { version: Lite.Version.DRAFT_07 }); + await stream.writer.u53(Lite.StreamId.Goaway); + await new Lite.Goaway("").encode(stream.writer, Lite.Version.DRAFT_07); + await settles(() => !wireOf(client).auth.active); + + const late = handle.add("late"); + const successor = await connect({ publish: new OriginProducer(), protocol, auth: handle }); + // The successor has not answered yet, so its predecessor's grant no longer applies. + expect(handle.grant.peek()).toBeUndefined(); + expect(token.grant.peek()).toBeUndefined(); + + answer(successor.server.auth.requests(), (token) => (token ? grant(["second"], []) : grant([], []))); + await late; + await waitFor(handle.grant, (g) => g?.publish.equals(patterns("second")) === true); + for (let i = 0; i < 10; i++) await nextTask(); + expect(seen).not.toContain("late"); + + client.abort(); + server.abort(); + successor.client.abort(); + successor.server.abort(); +}); + // moq-lite carries patterns, so every grant arrives exactly as issued. test("a fetch abandoned while its token waits never reaches the wire", async () => { const logged = spyOn(console, "debug"); diff --git a/js/net/src/auth_session.ts b/js/net/src/auth_session.ts index af77848b076..b35fe434e3b 100644 --- a/js/net/src/auth_session.ts +++ b/js/net/src/auth_session.ts @@ -203,6 +203,8 @@ class HandleState { attach(session: AuthSession) { this.vacant(); this.sessions.push(session); + // The successor is live now, so its predecessor's grants no longer apply. + this.refresh(); } /** A session closed: it presents nothing anymore. A handle of its own ends with it. */ @@ -582,9 +584,12 @@ export class AuthSession { return this.#byToken.get(token)?.grant; } - /** Present one of the handle's tokens on this session. */ + /** + * Present one of the handle's tokens on this session. A draining session takes no new + * tokens, so its verdict never ends one its successor carries. + */ offer(token: Token) { - if (this.#closed || this.#byToken.has(token)) return; + if (!this.active || this.#byToken.has(token)) return; if (!this.#wire && !this.#requests) { this.#state.verdict(token, new Unsupported()); return; @@ -859,6 +864,12 @@ export class AuthSession { result = null; break; } + // After a GOAWAY the peer must not see new streams: the successor presents it. + if (this.#draining()) { + refused = false; + result = new StreamError(StreamCode.GoingAway); + break; + } } // Already ended by the session's close. diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index 868e4c61203..4b16258a5d5 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -290,8 +290,11 @@ impl Attached { /// Present the handle's token `id` on this session, returning the answer when the /// session settles it at once: `Ok` when only a request can carry it, so nothing will /// answer, and [`Error::Unsupported`] when nothing can. + /// + /// A draining session takes no new tokens, so its verdict never ends one its successor + /// carries. fn offer(&mut self, id: u64, token: &Bytes) -> Option> { - if self.closed.is_some() { + if !self.active() { return None; } let answered = match (self.supported, self.requests) { @@ -2783,6 +2786,24 @@ mod tests { ); } + /// A token added while a session without AUTH drains rides only its successor, so the + /// draining session cannot end it as unsupported. + #[test] + fn a_draining_session_takes_no_new_token() { + let handle = Handle::new(); + let draining = Link::attach(&handle, false, false).unwrap(); + let (goaway, _protocol) = crate::goaway::Handle::new(false); + goaway.producer().send(crate::goaway::Goaway::new()).unwrap(); + draining.drain_with(std::sync::Arc::new(goaway)); + let successor = Link::attach(&handle, true, false).unwrap(); + + let auth = handle.present(Bytes::from_static(b"jwt")).unwrap(); + assert!(Selector::Token(auth.id()).ended(&handle.state.read()).is_none()); + let id = opened(&successor); + successor.granted(id, Grant::all()); + assert!(matches!(auth.poll_answered(&kio::Waiter::noop()), Poll::Ready(Ok(())))); + } + /// A refusal that may pass is presented again after a doubling backoff, and settles nothing. #[test] fn a_transient_refusal_backs_off() { From fad76b14ce24af0cecae7453d552393fbb10e594 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 13:20:26 -0700 Subject: [PATCH 35/45] fix(auth): a session that sent a GOAWAY presents no new AUTH stream The lite driver checked only a received GOAWAY before opening an AUTH stream, so a transient retry that woke after this side's own GOAWAY opened a fresh one on the draining session. Gate opens and retries on the link being active, which covers both directions. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/auth.rs | 29 +++++++++++++++++++++++++++-- rs/moq-net/src/lite/session.rs | 8 ++------ 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index 4b16258a5d5..bdcfe74f6e6 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -834,6 +834,12 @@ impl Link { self.with(|link| link.goaway = Some(goaway)); } + /// Whether the session still takes new work: open, with no GOAWAY sent or received. + #[cfg_attr(not(feature = "async"), allow(dead_code))] + pub(crate) fn active(&self) -> bool { + self.read(|link, _| link.active()) + } + /// 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))] @@ -1017,11 +1023,12 @@ impl Link { } /// The token to present again once a [`retry`](Self::retry) delay passes, or `None` - /// when it is no longer wanted here. + /// when it is no longer wanted here, or the session is draining and its successor + /// presents it instead. #[cfg_attr(not(feature = "async"), allow(dead_code))] pub(crate) fn reopen(&self, id: u64) -> Option { self.with(|link| { - if link.closed.is_some() { + if !link.active() { return None; } let slot = link.slots.get_mut(&id)?; @@ -2804,6 +2811,24 @@ mod tests { assert!(matches!(auth.poll_answered(&kio::Waiter::noop()), Poll::Ready(Ok(())))); } + /// A retry that wakes after this side sent a GOAWAY opens nothing: the successor presents it. + #[test] + fn a_draining_session_presents_no_retry() { + let link = Link::new(true); + let (goaway, _protocol) = crate::goaway::Handle::new(false); + let goaway = std::sync::Arc::new(goaway); + link.drain_with(goaway.clone()); + let _auth = link.present(Bytes::from_static(b"jwt"), false).unwrap(); + let id = opened(&link); + link.refused(id); + let busy = Error::Session(SessionError::TooManyRequests); + assert_eq!(link.retry(id, &busy), Some(RETRY_INITIAL)); + + goaway.producer().send(crate::goaway::Goaway::new()).unwrap(); + assert!(!link.active()); + assert_eq!(link.reopen(id), None); + } + /// A refusal that may pass is presented again after a doubling backoff, and settles nothing. #[test] fn a_transient_refusal_backs_off() { diff --git a/rs/moq-net/src/lite/session.rs b/rs/moq-net/src/lite/session.rs index 5dd49ff67f9..d5bc4571016 100644 --- a/rs/moq-net/src/lite/session.rs +++ b/rs/moq-net/src/lite/session.rs @@ -332,7 +332,6 @@ where session: session.clone(), version, handle: auth.clone(), - going_away: goaway.going_away.clone(), tokens: kio::Tasks::new(), violation: Default::default(), started: false, @@ -473,7 +472,6 @@ struct Present { session: S, version: Version, handle: crate::auth::Link, - going_away: crate::goaway::GoingAway, tokens: kio::Tasks>, /// Set by a token whose peer broke the protocol, which ends the session. violation: kio::Shared>, @@ -498,7 +496,6 @@ impl Present { session: self.session.clone(), version: self.version, handle: self.handle.clone(), - going_away: self.going_away.clone(), violation: self.violation.clone(), id, state: PresentState::Open { token }, @@ -520,7 +517,6 @@ struct PresentToken { session: S, version: Version, handle: crate::auth::Link, - going_away: crate::goaway::GoingAway, violation: kio::Shared>, id: u64, state: PresentState, @@ -580,8 +576,8 @@ impl PresentToken { loop { match &mut self.state { PresentState::Open { token } => { - // After a GOAWAY the peer must not see new streams. - if self.going_away.is_set() { + // After a GOAWAY, sent or received, the peer must not see new streams. + if !self.handle.active() { return Poll::Ready(Error::GoingAway); } let token = token.clone(); From c6f331e2d47ca31d4519f1a2c3b3c282b45e6410 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 15:01:15 -0700 Subject: [PATCH 36/45] fix(auth): a draining session's late refusal keeps its successor's grant During a GOAWAY overlap a token can sit unanswered on the draining session while the successor grants it. A late refusal there ended the token on every session, withdrawing the live grant and every route naming it. A draining session's verdict now ends a token only on that session once another session granted it, in Rust and JS. Co-Authored-By: Claude Opus 5.5 --- js/net/src/auth.test.ts | 47 ++++++++++++++++++++++++++++++++++++++ js/net/src/auth_session.ts | 6 +++++ rs/moq-net/src/auth.rs | 31 +++++++++++++++++++++++-- 3 files changed, 82 insertions(+), 2 deletions(-) diff --git a/js/net/src/auth.test.ts b/js/net/src/auth.test.ts index d5e3549b965..6b91a2484d1 100644 --- a/js/net/src/auth.test.ts +++ b/js/net/src/auth.test.ts @@ -754,6 +754,53 @@ test("lite-07 presents a token added after a GOAWAY only on the successor", asyn successor.server.abort(); }); +test("lite-07 ignores a draining session's late refusal once its successor granted", async () => { + const protocol = Lite.ALPN_07_WIP; + const handle = new Handle(); + const pair = createMockTransportPair(protocol); + const [client, server] = await Promise.all([ + connectSession({ url, transport: pair.client, publish: new OriginProducer().consume(), auth: handle }), + acceptSession({ transport: pair.server, url }), + ]); + const requests = server.auth.requests(); + const setup = await requests.next(); + if (!setup) throw new Error("missing setup token"); + const issued = [setup.accept(grant([], []))]; + const adding = handle.add("token"); + const unanswered = await requests.next(); + expect(unanswered && new TextDecoder().decode(unanswered.token)).toBe("token"); + + const stream = await Stream.open(pair.server, { version: Lite.Version.DRAFT_07 }); + await stream.writer.u53(Lite.StreamId.Goaway); + await new Lite.Goaway("").encode(stream.writer, Lite.Version.DRAFT_07); + await settles(() => !wireOf(client).auth.active); + + const successor = await connect({ publish: new OriginProducer(), protocol, auth: handle }); + answer(successor.server.auth.requests(), (token) => (token ? grant(["second"], []) : grant([], []))); + const token = await adding; + await waitFor(token.grant, (g) => g?.publish.equals(patterns("second")) === true); + + const warned = spyOn(console, "warn").mockImplementation(() => {}); + let ended = false; + void token.closed.then(() => { + ended = true; + }); + unanswered?.reject(SessionCode.Unauthorized, "stale"); + try { + await settles(() => warned.mock.calls.some(([line]) => line === "auth token refused")); + for (let i = 0; i < 10; i++) await nextTask(); + expect(ended).toBe(false); + expect(token.grant.peek()?.publish.equals(patterns("second"))).toBe(true); + } finally { + warned.mockRestore(); + for (const grant of issued) grant.close(); + client.abort(); + server.abort(); + successor.client.abort(); + successor.server.abort(); + } +}); + // moq-lite carries patterns, so every grant arrives exactly as issued. test("a fetch abandoned while its token waits never reaches the wire", async () => { const logged = spyOn(console, "debug"); diff --git a/js/net/src/auth_session.ts b/js/net/src/auth_session.ts index b35fe434e3b..30d72d46ddf 100644 --- a/js/net/src/auth_session.ts +++ b/js/net/src/auth_session.ts @@ -885,9 +885,15 @@ export class AuthSession { this.#ended.update((n) => n + 1); // Only the peer's verdict ends one of the handle's tokens on every session: a refusal, // Unsupported, or a clean close after a grant. Anything else leaves it for the next. + // A draining session's verdict is stale once another session granted the token. + const superseded = + !this.active && + owner !== undefined && + this.#state.sessions.some((session) => session !== this && session.grantOf(owner) !== undefined); const verdict = !this.#closed && !presented.withdrawn && + !superseded && (refused || result instanceof Unsupported || (result === null && granted)); if (owner && verdict) this.#state.verdict(owner, result); // Closed before the union shrinks, so the scope never reads it as a revocation. diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index bdcfe74f6e6..fe65a1b6a01 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -1046,12 +1046,18 @@ impl Link { /// leaves it for the next session to present. #[cfg_attr(not(feature = "async"), allow(dead_code))] pub(crate) fn ended(&self, id: u64, err: Error) { - self.with_state(|state, link| { - let link = attached(&mut state.links, link); + self.with_state(|state, link_id| { + // A draining session's verdict is stale once another session granted the token. + let superseded = !attached(&mut state.links, link_id).active() + && state.links.iter().any(|(other, link)| { + *other != link_id && link.slots.get(&id).is_some_and(|slot| slot.grant.is_some()) + }); + let link = attached(&mut state.links, link_id); let Some(slot) = link.slots.get_mut(&id) else { return; }; let verdict = !slot.setup + && !superseded && link.closed.is_none() && !slot.withdrawn && slot.ended.is_none() @@ -2811,6 +2817,27 @@ mod tests { assert!(matches!(auth.poll_answered(&kio::Waiter::noop()), Poll::Ready(Ok(())))); } + /// A draining session's late refusal ends the token there alone once its successor + /// granted it, so a stale verdict never withdraws a live grant. + #[test] + fn a_draining_refusal_leaves_the_successors_grant() { + let handle = Handle::new(); + let draining = Link::attach(&handle, true, false).unwrap(); + let auth = handle.present(Bytes::from_static(b"jwt")).unwrap(); + let id = opened(&draining); + let (goaway, _protocol) = crate::goaway::Handle::new(false); + goaway.producer().send(crate::goaway::Goaway::new()).unwrap(); + draining.drain_with(std::sync::Arc::new(goaway)); + + let successor = Link::attach(&handle, true, false).unwrap(); + assert_eq!(opened(&successor), id); + successor.granted(id, Grant::all()); + draining.refused(id); + draining.ended(id, Error::Session(SessionError::Unauthorized)); + assert!(Selector::Token(id).ended(&handle.state.read()).is_none()); + assert_eq!(auth.grant().peek(), Some(Grant::all())); + } + /// A retry that wakes after this side sent a GOAWAY opens nothing: the successor presents it. #[test] fn a_draining_session_presents_no_retry() { From 0c7acddebf573b197ceb22747909b684bc2e1283 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 17:14:54 -0700 Subject: [PATCH 37/45] fix(auth): a draining verdict defers to the active session, and a retry stays unanswered - A draining session's refusal is stale while an active session still presents the token, even mid-retry with no grant, so it no longer ends the successor's token. - A token waiting out a transient refusal counts as unanswered, so a publication it might cover waits instead of closing the session as unauthorized. - An ended auth handle stops the reconnect loop (moq-tokio and js/net) instead of redialing sessions the handle refuses. - A session's own handle has no successor: a token added after its GOAWAY fails with GoingAway, and any end of its stream is final. - moq-transport's presenter drops its retry loop: AUTH_ERROR has no transient code. - JS requests() throws Auth.Duplicate, like Rust; docs follow the handle's lifetime. Co-Authored-By: Claude Opus 5.5 --- doc/lib/js/net.md | 2 +- js/net/MIGRATE.md | 2 +- js/net/src/auth.test.ts | 106 ++++++++++++++++++++++++++- js/net/src/auth.ts | 2 +- js/net/src/auth_session.ts | 46 ++++++++++-- js/net/src/connection/reload.test.ts | 35 +++++++++ js/net/src/connection/reload.ts | 11 ++- rs/moq-net/src/auth.rs | 106 ++++++++++++++++++++++++--- rs/moq-net/src/ietf/auth.rs | 26 ++----- rs/moq-tokio/src/connection.rs | 34 +++++++++ 10 files changed, 326 insertions(+), 44 deletions(-) diff --git a/doc/lib/js/net.md b/doc/lib/js/net.md index 7c5e666677b..0d3c0f35b96 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 (opt in with `versions: ["moq-lite-07-wip", "moq-lite-06"]`, listing every fallback you still want) 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. A `Connection`'s `auth` handle outlives its sessions: a token added there is presented on every reconnect and keeps riding the announcements that name it, with no re-add or re-announce. A one-shot session's `auth` ends with it, unless `Connection.connect` was given a `new Auth.Handle()` as `auth`. +- **Authorization is in band.** On moq-lite 07 (opt in with `versions: ["moq-lite-07-wip", "moq-lite-06"]`, listing every fallback you still want) 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. A `Connection`'s `auth` handle (a getter on a pooled connection, following its URL) outlives its sessions: a token added there is presented on every reconnect and keeps riding the announcements that name it, with no re-add or re-announce. A one-shot session's `auth` ends with it, unless `Connection.connect` was given a `new Auth.Handle()` as `auth`. - **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/MIGRATE.md b/js/net/MIGRATE.md index ea578fe763f..0ddf4a92cf7 100644 --- a/js/net/MIGRATE.md +++ b/js/net/MIGRATE.md @@ -8,7 +8,7 @@ How to update your code for each breaking change, newest first. `Auth.Handle` is now a class: `new Auth.Handle()` backs no session yet, and `connect({ auth })` builds the session on it, so every session built on one presents the tokens it holds. A reconnecting `Connection` owns one as `connection.auth`; a route naming one of its `Auth.Auth`s keeps that token across a reconnect, so drop the code that added the token again and re-announced after one. -A handle backs one active session at a time, plus the ones draining after a GOAWAY: connecting another while one is still active rejects with `Auth.Duplicate`. +A handle backs one active session at a time, plus the ones draining after a GOAWAY: connecting another while one is still active rejects with `Auth.Duplicate`, and so does a second `requests()` call, which threw a plain `Error` before. `Established.auth` returns the session's handle, so its `grant` and each `Auth.Auth`'s `grant` read undefined once the session closes, instead of an empty grant. `Auth.Auth.closed` settles only on the peer's verdict or the handle's end (`handle.abort()`); a session ending is not one, except for a handle the session created for itself. `Auth.None` is removed; nothing returned it. diff --git a/js/net/src/auth.test.ts b/js/net/src/auth.test.ts index 6b91a2484d1..74898fa7b4a 100644 --- a/js/net/src/auth.test.ts +++ b/js/net/src/auth.test.ts @@ -129,7 +129,7 @@ describe.each([Lite.ALPN_07_WIP, Ietf.ALPN.DRAFT_17, Ietf.ALPN.DRAFT_22])("%s", token.close(); expect(await token.closed).toBeNull(); - await waitFor(handle.grant, (g) => g?.publish.equals(patterns("")) === false); + await waitFor(handle.grant, (g) => g?.publish.size === 0); second.client.abort(); second.server.abort(); }); @@ -701,12 +701,22 @@ test("lite-07 presents a transiently refused token again after a backoff", async added = true; }); await refuse(await requests.next()); + // Unanswered through the backoff, so a publication it might cover waits for the retry. + let ready: boolean | undefined; + void wireOf(client) + .auth.ready(Path.from("a"), "publish") + .then((value) => { + ready = value; + }); await refuse(await after(Milli(1000))); + expect(ready).toBeUndefined(); const accepted = await after(Milli(2000)); if (!accepted) throw new Error("the token was not presented again"); issued.push(accepted.accept(grant(["a"], []))); const token = await adding; expect(token.grant.peek()?.publish.equals(patterns("a"))).toBe(true); + await settles(() => ready !== undefined); + expect(ready).toBe(true); } finally { client.abort(); server.abort(); @@ -754,6 +764,27 @@ test("lite-07 presents a token added after a GOAWAY only on the successor", asyn successor.server.abort(); }); +test("lite-07 refuses a token added to a session's own handle after a GOAWAY", async () => { + const protocol = Lite.ALPN_07_WIP; + const pair = createMockTransportPair(protocol); + const [client, server] = await Promise.all([ + connectSession({ url, transport: pair.client, publish: new OriginProducer().consume() }), + acceptSession({ transport: pair.server, url }), + ]); + const stream = await Stream.open(pair.server, { version: Lite.Version.DRAFT_07 }); + await stream.writer.u53(Lite.StreamId.Goaway); + await new Lite.Goaway("").encode(stream.writer, Lite.Version.DRAFT_07); + await settles(() => !wireOf(client).auth.active); + + try { + const late = withTimeout(client.auth.add("late"), Nano.fromMilli(Milli(1000)), "add after GOAWAY hung"); + await expect(late).rejects.toBeInstanceOf(StreamError); + } finally { + client.abort(); + server.abort(); + } +}); + test("lite-07 ignores a draining session's late refusal once its successor granted", async () => { const protocol = Lite.ALPN_07_WIP; const handle = new Handle(); @@ -801,6 +832,79 @@ test("lite-07 ignores a draining session's late refusal once its successor grant } }); +test("lite-07 ignores a draining session's late refusal while its successor retries", async () => { + const clock = new Manual(); + const restore = clock.install(); + const warned = spyOn(console, "warn").mockImplementation(() => {}); + const refusals = () => warned.mock.calls.filter(([line]) => line === "auth token refused").length; + const protocol = Lite.ALPN_07_WIP; + const handle = new Handle(); + const pair = createMockTransportPair(protocol); + const [client, server] = await Promise.all([ + connectSession({ url, transport: pair.client, publish: new OriginProducer().consume(), auth: handle }), + acceptSession({ transport: pair.server, url }), + ]); + const requests = server.auth.requests(); + const issued: Issued[] = []; + const setup = await requests.next(); + if (!setup) throw new Error("missing setup token"); + issued.push(setup.accept(grant([], []))); + const adding = handle.add("token"); + const unanswered = await requests.next(); + expect(unanswered && new TextDecoder().decode(unanswered.token)).toBe("token"); + + const stream = await Stream.open(pair.server, { version: Lite.Version.DRAFT_07 }); + await stream.writer.u53(Lite.StreamId.Goaway); + await new Lite.Goaway("").encode(stream.writer, Lite.Version.DRAFT_07); + await settles(() => !wireOf(client).auth.active); + + const next = await connect({ publish: new OriginProducer(), protocol, auth: handle }); + const successorRequests = next.server.auth.requests(); + try { + const successorSetup = await successorRequests.next(); + if (!successorSetup) throw new Error("missing successor setup token"); + issued.push(successorSetup.accept(grant([], []))); + const first = await successorRequests.next(); + if (!first) throw new Error("the successor did not present the token"); + const firstGrant = first.accept(grant(["second"], [])); + const token = await adding; + await waitFor(token.grant, (g) => g?.publish.equals(patterns("second")) === true); + let ended = false; + void token.closed.then(() => { + ended = true; + }); + + // The successor's grant ends with a refusal that may pass, so it backs off with no grant. + const before = refusals(); + firstGrant.revoke(SessionCode.TooManyRequests, "busy"); + await settles(() => refusals() > before); + await waitFor(token.grant, (g) => g === undefined); + + // The draining session's stale refusal lands during that backoff. + const stale = refusals(); + unanswered?.reject(SessionCode.Unauthorized, "stale"); + await settles(() => refusals() > stale); + for (let i = 0; i < 10; i++) await nextTask(); + expect(ended).toBe(false); + + const retried = successorRequests.next(); + clock.advance(Nano.fromMilli(Milli(1000))); + const again = await retried; + if (!again) throw new Error("the successor did not present the token again"); + issued.push(again.accept(grant(["third"], []))); + await waitFor(token.grant, (g) => g?.publish.equals(patterns("third")) === true); + expect(ended).toBe(false); + } finally { + for (const grant of issued) grant.close(); + client.abort(); + server.abort(); + next.client.abort(); + next.server.abort(); + warned.mockRestore(); + restore(); + } +}); + // moq-lite carries patterns, so every grant arrives exactly as issued. test("a fetch abandoned while its token waits never reaches the wire", async () => { const logged = spyOn(console, "debug"); diff --git a/js/net/src/auth.ts b/js/net/src/auth.ts index 5d2189cc92d..cdfe452eee0 100644 --- a/js/net/src/auth.ts +++ b/js/net/src/auth.ts @@ -89,7 +89,7 @@ export interface Issued { /** The tokens the peer presents, for an application that answers them itself. */ export interface Requests { - /** The next token the peer presents, or undefined once the session ends. */ + /** The next token the peer presents, or undefined once the handle ends. */ next(): Promise; /** Refuse every later token. */ close(): void; diff --git a/js/net/src/auth_session.ts b/js/net/src/auth_session.ts index 30d72d46ddf..2e60c544e3e 100644 --- a/js/net/src/auth_session.ts +++ b/js/net/src/auth_session.ts @@ -22,7 +22,7 @@ export class Unsupported extends Error { } } -/** A handle already backs a session that is open and not draining. */ +/** A handle already backs a session that is open and not draining, or its requests were already taken. */ export class Duplicate extends Error { constructor(message = "auth handle already backs an active session") { super(message); @@ -228,6 +228,10 @@ class HandleState { add(bytes: Uint8Array): Token { if (this.closed) throw this.closed; + // A draining session takes no new tokens, and a handle of its own has no successor. + if (this.own && !this.sessions.some((session) => session.active)) { + throw new StreamError(StreamCode.GoingAway); + } const token = new Token(bytes, (token) => this.withdraw(token)); this.tokens.add(token); for (const session of [...this.sessions]) session.offer(token); @@ -279,6 +283,15 @@ export function vacant(handle: Handle) { stateOf(handle).vacant(); } +/** + * Why `handle` ended, or undefined while it can still back a session. + * + * @internal + */ +export function ended(handle: Handle): Error | undefined { + return stateOf(handle).closed; +} + /** * The tokens this side presents and the grant they earn, across every session built on it. * @@ -325,14 +338,14 @@ export class Handle { * publishes and consumes for the connection's own credential and refusing any other. * * Call it as soon as connect or accept resolves, in the same task: whoever answers is - * decided once, right after. Throws once that has happened. + * decided once, right after. Throws {@link Duplicate} once that has happened. * * The peer's stream credit bounds how many tokens wait unanswered, since each holds its * stream open until answered or withdrawn. A withdrawn token leaves the queue. */ requests(): Requests { const state = stateOf(this); - if (state.acceptor !== "undecided") throw new Error("auth requests already taken or answered by default"); + if (state.acceptor !== "undecided") throw new Duplicate("auth requests already taken or answered by default"); const queue = new RequestQueue(); // A session of its own that carries no AUTH never receives a token. if (state.closed || (state.own && !state.live?.negotiated)) queue.close(); @@ -584,6 +597,12 @@ export class AuthSession { return this.#byToken.get(token)?.grant; } + /** Whether this session still presents the handle's `token`. */ + presents(token: Token): boolean { + const presented = this.#byToken.get(token); + return presented !== undefined && !presented.ended && !presented.withdrawn; + } + /** * Present one of the handle's tokens on this session. A draining session takes no new * tokens, so its verdict never ends one its successor carries. @@ -852,8 +871,9 @@ export class AuthSession { // carries such a code. if (!refused || !transient(result) || this.#closed || presented.withdrawn) break; presented.grant = undefined; - // Answered, so nothing waits on the token through the backoff. - this.#settle(presented); + // Unanswered again, as on its first presentation: a request or publication the token + // might cover waits for the retry's verdict rather than being judged without it. + this.#unsettle(presented); this.#recompute(); delay = delay === undefined ? RETRY_INITIAL : Milli(Math.min(delay * 2, RETRY_MAX)); presented.retry = new AbortController(); @@ -885,16 +905,18 @@ export class AuthSession { this.#ended.update((n) => n + 1); // Only the peer's verdict ends one of the handle's tokens on every session: a refusal, // Unsupported, or a clean close after a grant. Anything else leaves it for the next. - // A draining session's verdict is stale once another session granted the token. + // A draining session's verdict is stale while an active session still presents the + // token: that session's own answer decides, even mid-retry with no grant right now. const superseded = !this.active && owner !== undefined && - this.#state.sessions.some((session) => session !== this && session.grantOf(owner) !== undefined); + this.#state.sessions.some((session) => session !== this && session.active && session.presents(owner)); + // A handle of the session's own has no next session to present it, so any end is final. const verdict = !this.#closed && !presented.withdrawn && !superseded && - (refused || result instanceof Unsupported || (result === null && granted)); + (this.#state.own || refused || result instanceof Unsupported || (result === null && granted)); if (owner && verdict) this.#state.verdict(owner, result); // Closed before the union shrinks, so the scope never reads it as a revocation. if (closed) this.close(); @@ -915,6 +937,14 @@ export class AuthSession { if (presented.token.byteLength > 0 && !this.#closed) this.#pending.update((n) => n - 1); } + /** Count `presented` as unanswered again, as a retry presents it anew. */ + #unsettle(presented: Presented) { + if (!presented.isAnswered) return; + presented.isAnswered = false; + if (presented.setup) this.#setupPending.update((n) => n + 1); + if (presented.token.byteLength > 0) this.#pending.update((n) => n + 1); + } + #recompute() { const granted: Grant[] = []; for (const presented of this.#presented) { diff --git a/js/net/src/connection/reload.test.ts b/js/net/src/connection/reload.test.ts index 65f6a3bf4a9..182425e7b14 100644 --- a/js/net/src/connection/reload.test.ts +++ b/js/net/src/connection/reload.test.ts @@ -262,6 +262,41 @@ test("an announced request follows the reconnect on the same handle", async () = } }); +test("an ended auth handle gives up instead of retrying", async () => { + const original = globalThis.WebTransport; + const url = new URL("https://example.com/"); + const servers: Promise<{ abort(): void }>[] = []; + const stub = function StubWebTransport() { + const pair = createMockTransportPair(Lite.ALPN_06); + servers.push(accept(pair.server, url)); + return pair.client; + }; + globalThis.WebTransport = stub as unknown as typeof WebTransport; + + // An unbounded retry window, so only the ended handle can stop the loop. + const reload = new Reload({ + enabled: true, + url, + websocket: { enabled: false }, + delay: { initial: Milli(1), multiplier: 2, max: Milli(1), timeout: Milli(0) }, + }); + const warned = spyOn(console, "warn").mockImplementation(() => {}); + try { + await waitUntil(() => reload.established.peek() !== undefined); + const aborted = new Error("auth handle aborted"); + reload.auth.abort(aborted); + (await servers[0])?.abort(); + + await waitUntil(() => reload.error.peek() !== undefined); + expect(reload.error.peek()).toBe(aborted); + expect(servers.length).toBe(1); + } finally { + warned.mockRestore(); + reload.close(); + globalThis.WebTransport = original; + } +}); + test("a reload that gives up keeps requests pending until it is disposed", async () => { const original = globalThis.WebTransport; const url = new URL("https://example.com/"); diff --git a/js/net/src/connection/reload.ts b/js/net/src/connection/reload.ts index 24d4744eb54..b7bd1e32625 100644 --- a/js/net/src/connection/reload.ts +++ b/js/net/src/connection/reload.ts @@ -2,6 +2,7 @@ import { Instant, Milli, Nano, now, sleep } from "@moq/clock"; import { Effect, type GetPromise, type Getter, Once, Signal } from "@moq/signals"; import * as Announce from "../announced.ts"; import { Handle as AuthHandle } from "../auth.ts"; +import { ended } from "../auth_session.ts"; import { Allocator } from "../bandwidth.ts"; import { error, SessionCode, SessionError } from "../error.ts"; import type { Consumer as OriginConsumer, Producer as OriginProducer } from "../origin.ts"; @@ -152,7 +153,8 @@ export class Reload { * The auth handle every session of the loop is built on, spanning reconnects. * * Each session presents every token it holds, so a route naming one keeps it across a - * reconnect, and its grant follows the live session. It ends when the loop closes. + * reconnect, and its grant follows the live session. It ends when the loop closes, and + * aborting it stops the loop at its next reconnect, with the abort's error. */ readonly auth = new AuthHandle(); @@ -496,6 +498,13 @@ export class Reload { // Treat teardown as cancellation, not a connection failure. if (signal.aborted) return; + // An ended auth handle refuses every later session, so no retry can recover. + const closed = ended(this.auth); + if (closed) { + this.#giveUp(closed); + return; + } + console.warn("connection error:", err); this.#retry(effect, connected, err); } diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index fe65a1b6a01..893093efff6 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -553,6 +553,10 @@ impl Handle { if let Some(err) = &state.closed { return Err(err.clone()); } + // A draining session takes no new tokens, and a handle of its own has no successor. + if state.bound && !state.links.values().any(Attached::active) { + return Err(Error::GoingAway); + } let id = state.next_id; state.next_id += 1; state.tokens.insert( @@ -1012,8 +1016,9 @@ impl Link { slot.refused = false; slot.streamed = false; slot.grant = None; - // Answered, so nothing waits on the token through the backoff. - slot.answered = Some(Err(err.clone())); + // Unanswered again, as on its first presentation: a request or publication the + // token might cover waits for the retry's verdict rather than being judged without it. + slot.answered = None; let delay = slot.retry.map_or(RETRY_INITIAL, |delay| (delay * 2).min(RETRY_MAX)); slot.retry = Some(delay); link.epoch += 1; @@ -1043,15 +1048,22 @@ impl Link { /// End the token on this session: refused or revoked by the peer, withdrawn by us, or /// its stream lost. A token that was never answered records `err` as its answer. The /// peer's verdict ends one of the handle's tokens on every session; anything else - /// leaves it for the next session to present. + /// leaves it for the next session to present, except on a handle of the session's own. #[cfg_attr(not(feature = "async"), allow(dead_code))] pub(crate) fn ended(&self, id: u64, err: Error) { self.with_state(|state, link_id| { - // A draining session's verdict is stale once another session granted the token. + // A draining session's verdict is stale while an active session still presents the + // token: that session's own answer decides, even mid-retry with no grant right now. let superseded = !attached(&mut state.links, link_id).active() && state.links.iter().any(|(other, link)| { - *other != link_id && link.slots.get(&id).is_some_and(|slot| slot.grant.is_some()) + *other != link_id + && link.active() && link + .slots + .get(&id) + .is_some_and(|slot| slot.ended.is_none() && !slot.withdrawn) }); + // A handle of the session's own has no next session to present it, so any end is final. + let bound = state.bound; let link = attached(&mut state.links, link_id); let Some(slot) = link.slots.get_mut(&id) else { return; @@ -1061,7 +1073,8 @@ impl Link { && link.closed.is_none() && !slot.withdrawn && slot.ended.is_none() - && (slot.refused + && (bound + || slot.refused || matches!(err, Error::Unsupported) // The peer ended the grant without revoking it. || (matches!(err, Error::Cancel) && slot.grant.is_some())); @@ -1511,12 +1524,12 @@ pub struct Requests { } impl Requests { - /// Poll for the next token, or `None` once the session ends. + /// Poll for the next token, or `None` once the handle ends. pub fn poll_next(&mut self, waiter: &kio::Waiter) -> Poll> { self.queue.poll_next(waiter) } - /// Wait for the next token the peer presents, or `None` once the session ends. + /// Wait for the next token the peer presents, or `None` once the handle ends. #[cfg(feature = "async")] pub async fn next(&mut self) -> Option { kio::wait(|waiter| self.poll_next(waiter)).await @@ -2508,6 +2521,29 @@ mod tests { assert!(gate.poll_denied(&waiter).is_pending()); } + /// A token waiting out a transient refusal is unanswered, so a publication it might + /// cover waits for the retry instead of being judged unauthorized. + #[test] + fn a_retrying_token_holds_a_publication() { + let handle = Link::new(true); + let _token = handle.present(Bytes::from_static(b"jwt"), false).unwrap(); + let id = opened(&handle); + handle.refused(id); + let busy = Error::Session(SessionError::TooManyRequests); + assert_eq!(handle.retry(id, &busy), Some(RETRY_INITIAL)); + + let mut gate = Gate::new(handle.clone(), crate::Path::new("room").to_owned(), Direction::Publish); + let waiter = kio::Waiter::noop(); + assert!( + gate.poll_ready(&waiter).is_pending(), + "judged before the retry answered" + ); + handle.reopen(id); + handle.granted(id, Grant::all()); + assert!(gate.poll_ready(&waiter).is_ready()); + assert!(handle.within_grant(Direction::Publish, "room")); + } + /// A route withdrawn while its request waits on a token ends the wait, and the request /// is refused against the union as it stands. #[test] @@ -2799,6 +2835,28 @@ mod tests { ); } + /// A session's own handle has no successor: a token added after its GOAWAY fails at once, + /// and one whose stream ends without a verdict ends for good. + #[test] + fn a_sessions_own_handle_ends_its_tokens() { + let link = Link::new(true); + let auth = link.present(Bytes::from_static(b"jwt"), false).unwrap(); + let id = opened(&link); + link.ended(id, Error::Stream(crate::StreamError::Internal)); + assert!(matches!( + auth.poll_answered(&kio::Waiter::noop()), + Poll::Ready(Err(Error::Stream(_))) + )); + + let (goaway, _protocol) = crate::goaway::Handle::new(false); + goaway.producer().send(crate::goaway::Goaway::new()).unwrap(); + link.drain_with(std::sync::Arc::new(goaway)); + assert!(matches!( + link.present(Bytes::from_static(b"late"), false), + Err(Error::GoingAway) + )); + } + /// A token added while a session without AUTH drains rides only its successor, so the /// draining session cannot end it as unsupported. #[test] @@ -2817,8 +2875,8 @@ mod tests { assert!(matches!(auth.poll_answered(&kio::Waiter::noop()), Poll::Ready(Ok(())))); } - /// A draining session's late refusal ends the token there alone once its successor - /// granted it, so a stale verdict never withdraws a live grant. + /// A draining session's late refusal ends the token there alone while its successor + /// presents it, so a stale verdict never withdraws a live grant. #[test] fn a_draining_refusal_leaves_the_successors_grant() { let handle = Handle::new(); @@ -2838,6 +2896,34 @@ mod tests { assert_eq!(auth.grant().peek(), Some(Grant::all())); } + /// A draining session's late refusal leaves a token its successor is still deciding, + /// including one the successor granted and then transiently refused. + #[test] + fn a_draining_refusal_defers_to_the_successors_retry() { + let handle = Handle::new(); + let draining = Link::attach(&handle, true, false).unwrap(); + let auth = handle.present(Bytes::from_static(b"jwt")).unwrap(); + let id = opened(&draining); + let (goaway, _protocol) = crate::goaway::Handle::new(false); + goaway.producer().send(crate::goaway::Goaway::new()).unwrap(); + draining.drain_with(std::sync::Arc::new(goaway)); + + let successor = Link::attach(&handle, true, false).unwrap(); + assert_eq!(opened(&successor), id); + successor.granted(id, Grant::all()); + successor.refused(id); + let busy = Error::Session(SessionError::TooManyRequests); + assert_eq!(successor.retry(id, &busy), Some(RETRY_INITIAL)); + + draining.refused(id); + draining.ended(id, Error::Session(SessionError::Unauthorized)); + assert!(Selector::Token(id).ended(&handle.state.read()).is_none()); + + assert_eq!(successor.reopen(id).as_deref(), Some(b"jwt".as_slice())); + successor.granted(id, Grant::all()); + assert_eq!(auth.grant().peek(), Some(Grant::all())); + } + /// A retry that wakes after this side sent a GOAWAY opens nothing: the successor presents it. #[test] fn a_draining_session_presents_no_retry() { diff --git a/rs/moq-net/src/ietf/auth.rs b/rs/moq-net/src/ietf/auth.rs index 9394dc41b7e..923b4c19291 100644 --- a/rs/moq-net/src/ietf/auth.rs +++ b/rs/moq-net/src/ietf/auth.rs @@ -295,27 +295,11 @@ where let going_away = going_away.clone(); let violation = violation.clone(); tasks.push(async move { - let mut token = token; - let err = loop { - // After a GOAWAY the peer must not see new requests. - if going_away.is_set() { - break Error::GoingAway; - } - let err = present.run(token).await; - let Some(delay) = present.handle.retry(present.id, &err) else { - break err; - }; - tracing::debug!(?delay, "auth token refused transiently; presenting it again"); - let mut deadline = crate::time::Deadline::after(&present.runtime, delay); - kio::wait(|waiter| match present.handle.poll_withdrawn(present.id, waiter) { - Poll::Ready(()) => Poll::Ready(()), - Poll::Pending => deadline.poll(waiter), - }) - .await; - match present.handle.reopen(present.id) { - Some(next) => token = next, - None => break Error::Cancel, - } + // After a GOAWAY the peer must not see new requests. AUTH_ERROR carries no code + // that may pass, so unlike moq-lite a refusal is never presented again. + let err = match going_away.is_set() { + true => Error::GoingAway, + false => present.run(token).await, }; let err = crate::auth::protocol_error(err); if matches!(err, Error::ProtocolViolation) { diff --git a/rs/moq-tokio/src/connection.rs b/rs/moq-tokio/src/connection.rs index e05ced89f6a..00932338119 100644 --- a/rs/moq-tokio/src/connection.rs +++ b/rs/moq-tokio/src/connection.rs @@ -873,6 +873,11 @@ impl Connection { if !timeout.is_zero() && retry.start.elapsed() >= timeout { return Err(timeout_error(timeout, retry.last_error.as_ref())); } + // An ended auth handle refuses every later session, so no dial can recover. A fresh + // watch only reports an error once its handle has ended. + if let Poll::Ready(Err(err)) = shared.auth.grant().poll_changed(&kio::Waiter::noop()) { + return Err(err.into()); + } match Self::dial_any(shared, &client, &addrs, &mut draining).await { Ok((addr, dialed)) => { @@ -1307,6 +1312,7 @@ impl Connection { /// presented on each reconnect, so a route naming one keeps it, and its grant follows /// the live session. It ends with the connection: [`Error::MoqNet`]'s error, or /// [`moq_net::Error::Unauthorized`] for a rejection, or [`moq_net::Error::Cancel`]. + /// Aborting it ends the connection at its next reconnect, with the abort's error. pub fn auth(&self) -> moq_net::auth::Handle { self.auth.clone() } @@ -2566,6 +2572,34 @@ mod tests { assert!(matches!(closed, moq_net::Error::Cancel), "{closed:?}"); } + /// Ending the connection's auth handle ends the loop at its next reconnect, rather than + /// redialing sessions the handle refuses. + #[cfg(feature = "tcp")] + #[tokio::test] + async fn an_ended_auth_handle_ends_the_loop() { + const WAIT: Duration = Duration::from_secs(10); + + let (addr, mut accepted, _task) = serve(crate::origin::spawn()).await; + let mut config = crate::connect::Config::default(); + config.backoff.initial = Some(MIN_BACKOFF); + config.backoff.max = Some(MIN_BACKOFF); + let connection = config + .init(Default::default()) + .unwrap() + .connect(url(&format!("tcp://{addr}/"))); + + let session = tokio::time::timeout(WAIT, accepted.recv()).await.unwrap().unwrap(); + connection.auth().abort(moq_net::Error::App(9)); + session.abort(moq_net::Error::Cancel); + let closed = tokio::time::timeout(WAIT, connection.closed()) + .await + .expect("the loop kept redialing on an ended auth handle"); + assert!( + matches!(closed, Err(Error::MoqNet(moq_net::Error::App(9)))), + "{closed:?}" + ); + } + /// A session that ends the loop reports through `closed`, not as a retry reason. #[cfg(feature = "tcp")] #[tokio::test] From c0b800c7a18bc681952b43eb10eae6572635c191 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 17:37:51 -0700 Subject: [PATCH 38/45] fix(js/net): a GOING_AWAY refusal leaves a shared token for the successor moq-transport's AUTH_ERROR GOING_AWAY judged nothing, but the presenter counted it as a refusal, so it ended a shared handle's token before the next session could present it. Matches the Rust presenter, which never marks GoingAway as refused. Co-Authored-By: Claude Opus 5.5 --- js/net/src/auth.test.ts | 33 +++++++++++++++++++++++++++++++++ js/net/src/auth_session.ts | 10 +++++----- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/js/net/src/auth.test.ts b/js/net/src/auth.test.ts index 74898fa7b4a..64a31e31386 100644 --- a/js/net/src/auth.test.ts +++ b/js/net/src/auth.test.ts @@ -1071,6 +1071,39 @@ test("a reset AUTH stream fails the token on moq-transport", async () => { } }); +test("a GOING_AWAY refusal leaves a shared token for the next session on moq-transport", async () => { + const protocol = Ietf.ALPN.DRAFT_22; + const handle = new Handle(); + const first = await connect({ publish: new OriginProducer(), protocol, auth: handle }); + await waitFor(handle.grant, (g) => g !== undefined); + const read = spyOn(IetfAuthWire.prototype, "read").mockResolvedValue({ + refused: new StreamError(StreamCode.GoingAway), + }); + const warned = spyOn(console, "warn").mockImplementation(() => {}); + let rejected: unknown; + const adding = handle.add("token"); + adding.catch((err) => { + rejected = err; + }); + try { + await settles(() => warned.mock.calls.some(([line]) => line === "auth token refused")); + for (let i = 0; i < 10; i++) await nextTask(); + expect(rejected).toBeUndefined(); + } finally { + read.mockRestore(); + warned.mockRestore(); + first.client.abort(); + first.server.abort(); + } + + const second = await connect({ publish: new OriginProducer(), protocol, auth: handle }); + answer(second.server.auth.requests(), (token) => (token ? grant(["second"], []) : grant([], []))); + const token = await adding; + await waitFor(token.grant, (g) => g?.publish.equals(patterns("second")) === true); + second.client.abort(); + second.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_session.ts b/js/net/src/auth_session.ts index 2e60c544e3e..a0523650b35 100644 --- a/js/net/src/auth_session.ts +++ b/js/net/src/auth_session.ts @@ -840,12 +840,12 @@ export class AuthSession { console.warn("auth token refused", reply.refused); // A refused setup token leaves an empty union, not an unknown (unrestricted) // one. A grant the acceptor could not tell, or never judged, is unknown, not empty. - const unjudged = - reply.refused instanceof Unsupported || - (reply.refused instanceof StreamError && reply.refused.code === StreamCode.GoingAway); - if (!unjudged) this.#replied = true; + const goingAway = + reply.refused instanceof StreamError && reply.refused.code === StreamCode.GoingAway; + if (!goingAway && !(reply.refused instanceof Unsupported)) this.#replied = true; result = reply.refused; - refused = true; + // A peer going away judged nothing, so the successor presents the token instead. + refused = !goingAway; stream.close(); break; } From 82db4389d3a3f3dd18f522f4e7f11a4c8dc634be Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 24 Oct 2026 22:38:06 -0700 Subject: [PATCH 39/45] fix: lint and help-text fixes after merging main Use moq_clock::timeout in the token holder's tests, keep an intra-doc link out of --connect-token's help, mark rust_connect as indirectly invoked, and sort an import. Co-Authored-By: Claude Opus 5.5 --- js/net/src/connection/tokens.test.ts | 2 +- rs/moq-ffi/MIGRATE.md | 1 + rs/moq-tokio/src/connect.rs | 2 +- rs/moq-tokio/src/connection/tokens.rs | 14 +++++++------- test/interop/interop.sh | 3 ++- 5 files changed, 12 insertions(+), 10 deletions(-) diff --git a/js/net/src/connection/tokens.test.ts b/js/net/src/connection/tokens.test.ts index a27d78aabda..c08732bb2db 100644 --- a/js/net/src/connection/tokens.test.ts +++ b/js/net/src/connection/tokens.test.ts @@ -3,9 +3,9 @@ import type { Auth, Handle } from "../auth.ts"; import * as Ietf from "../ietf/index.ts"; import { createMockTransportPair } from "../mock.ts"; import { Reader } from "../stream.ts"; +import type { Established } from "./established.ts"; import { offered } from "./extensions.ts"; import { exchangeSetup } from "./handshake.ts"; -import type { Established } from "./established.ts"; import { credential, extra, Held, hold } from "./tokens.ts"; const URL_ = new URL("https://relay.example.com/room"); diff --git a/rs/moq-ffi/MIGRATE.md b/rs/moq-ffi/MIGRATE.md index 6f795c923b9..900e45bc236 100644 --- a/rs/moq-ffi/MIGRATE.md +++ b/rs/moq-ffi/MIGRATE.md @@ -8,6 +8,7 @@ These are the UniFFI names; each language wrapper has its own `MIGRATE.md` in it ### `MoqClientConfig` gains `tokens` It defaults to empty in every generated binding; a Rust struct literal adds `tokens: vec![]` or ends with `..Default::default()`. + ### Connection state `MoqClient::connect` returns a connecting session without waiting for its first dial to succeed, natively and in the browser. Read `MoqSession::state()` and pass its `revision` to `state_changed(after)` to observe first-dial failures and reconnects. `MoqConnectionState` carries `phase`, `connects`, `failure`, and `version` together. These replace `status()`, `connects()`, and both `failure()` polls. Cancel the returned session to stop dialing; cancelling or releasing the client does not close it. diff --git a/rs/moq-tokio/src/connect.rs b/rs/moq-tokio/src/connect.rs index 4f62e93216f..ad7d1911d5d 100644 --- a/rs/moq-tokio/src/connect.rs +++ b/rs/moq-tokio/src/connect.rs @@ -519,7 +519,7 @@ pub struct Config { /// while an offered version can't carry it in SETUP (lite before 07). Each other /// token rides its own AUTH stream, widening the session's grant. A URL that /// already has a `?jwt=` keeps it as the connection credential, and then every - /// token rides AUTH. Replace them while running with [`crate::Client::tokens`]. + /// token rides AUTH. Replace them while running through `Client::tokens`. #[serde(default, skip_serializing_if = "Vec::is_empty")] #[usage( name = "connect-token", diff --git a/rs/moq-tokio/src/connection/tokens.rs b/rs/moq-tokio/src/connection/tokens.rs index fff4d22f480..974aefab15e 100644 --- a/rs/moq-tokio/src/connection/tokens.rs +++ b/rs/moq-tokio/src/connection/tokens.rs @@ -288,7 +288,7 @@ mod tests { } async fn next_token(pair: &mut Pair) -> auth::Request { - tokio::time::timeout(std::time::Duration::from_secs(5), pair.requests.recv()) + moq_clock::timeout(std::time::Duration::from_secs(5), pair.requests.recv()) .await .expect("no token presented") .unwrap() @@ -308,7 +308,7 @@ mod tests { } presented.sort(); assert_eq!(presented, [b"a".to_vec(), b"b".to_vec()]); - let again = tokio::time::timeout(std::time::Duration::from_secs(1), pair.requests.recv()).await; + let again = moq_clock::timeout(std::time::Duration::from_secs(1), pair.requests.recv()).await; assert!(again.is_err(), "the connection credential was presented twice"); } @@ -332,14 +332,14 @@ mod tests { tokens.set(vec!["connection".parse().unwrap(), "new".parse().unwrap()]); let request = next_token(&mut pair).await; assert_eq!(request.token().as_ref(), b"new"); - let early = tokio::time::timeout(std::time::Duration::from_secs(1), old.closed()).await; + let early = moq_clock::timeout(std::time::Duration::from_secs(1), old.closed()).await; assert!(early.is_err(), "withdrawn before its replacement was answered"); let new = request.accept(auth::Grant::all()); - tokio::time::timeout(std::time::Duration::from_secs(5), old.closed()) + moq_clock::timeout(std::time::Duration::from_secs(5), old.closed()) .await .expect("the replaced token was never withdrawn"); - let kept = tokio::time::timeout(std::time::Duration::from_secs(1), new.closed()).await; + let kept = moq_clock::timeout(std::time::Duration::from_secs(1), new.closed()).await; assert!(kept.is_err(), "the replacement was withdrawn"); } @@ -355,7 +355,7 @@ mod tests { let request = next_token(&mut pair).await; assert_eq!(request.token().as_ref(), b"new"); request.reject(moq_net::SessionError::Unauthorized, "expired"); - tokio::time::timeout(std::time::Duration::from_secs(5), old.closed()) + moq_clock::timeout(std::time::Duration::from_secs(5), old.closed()) .await .expect("the replaced token outlived its refused replacement"); } @@ -367,7 +367,7 @@ mod tests { let pair = pair_on("moq-transport-14").await; let mut held = Held::default(); held.update(&pair.auth, vec!["a".parse().unwrap(), "b".parse().unwrap()]); - tokio::time::timeout( + moq_clock::timeout( std::time::Duration::from_secs(5), std::future::poll_fn(|cx| held.poll_answers(cx)), ) diff --git a/test/interop/interop.sh b/test/interop/interop.sh index f3fd035cd9a..03abef5a90e 100755 --- a/test/interop/interop.sh +++ b/test/interop/interop.sh @@ -890,8 +890,9 @@ token_url() { } # Set `connect` to moq-cli's arguments for , moving a `?jwt=` token to -# `--connect-token` so it rides SETUP on $AUTH_VERSION. The binding clients keep it +# `--connect-token` so it rides SETUP where the version carries one. The binding clients keep it # in the URL, which covers the query form. +# shellcheck disable=SC2329 # invoked from the indirectly invoked publisher and subscriber rust_connect() { case "$1" in *'?jwt='*) connect=(--connect "${1%%\?jwt=*}" --connect-token "${1#*\?jwt=}") ;; From 486795204458b7cf8ed53f772be2c3e0daba7f75 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 24 Oct 2026 23:47:52 -0700 Subject: [PATCH 40/45] fix(auth): hold configured tokens before the dial, and per fallback URL moq-tokio registered the configured tokens after the dial returned, but the dial had already started its session's driver, so a broadcast published before the dial could be judged against the connection credential's grant alone and close the session before the token that covers it was presented. Each candidate's tokens are now held before its dial starts; the new test fails with the old ordering. js/net decided the SETUP token from the primary URL even when the WebSocket fallback, dialed at its own URL, won. A fallback URL carrying its own jwt then sent two different credentials. The winning transport's URL now decides the SETUP token and the tokens held on AUTH. Co-Authored-By: Claude Opus 5.5 --- js/net/src/connection/connect.ts | 41 ++++--- js/net/src/connection/tokens.test.ts | 17 ++- js/net/src/connection/tokens.ts | 15 +++ rs/moq-tokio/src/client.rs | 24 ++-- rs/moq-tokio/src/connect/token.rs | 1 + rs/moq-tokio/src/connection.rs | 153 ++++++++++++++++++++++++-- rs/moq-tokio/src/connection/tokens.rs | 13 +-- 7 files changed, 215 insertions(+), 49 deletions(-) diff --git a/js/net/src/connection/connect.ts b/js/net/src/connection/connect.ts index 205f7f9ca12..3785e754a0d 100644 --- a/js/net/src/connection/connect.ts +++ b/js/net/src/connection/connect.ts @@ -18,7 +18,7 @@ import type { Established } from "./established.ts"; import * as Extensions from "./extensions.ts"; import { DISCOVERY_REMOVED, forwardAnnounced } from "./forward.ts"; import { exchangeSetup, legacyPeerImplementation } from "./handshake.ts"; -import { type Credential, credential, hold, withJwt } from "./tokens.ts"; +import { type Credential, credential, fallback, hold } from "./tokens.ts"; // Default head start for WebTransport before attempting the WebSocket fallback. const DEFAULT_WEBSOCKET_DELAY_MS = Milli(500); @@ -328,12 +328,12 @@ export async function connect({ url, ...props }: ConnectProps): Promise {}; const tokens = props.tokens ?? []; - const dial = credential(url, tokens, offer(props.versions).setupToken); - const pending = connectInner(dial, props, abort); + const pending = connectInner(url, tokens, props, abort); try { // A `pending` rejection propagates unless the abort or timeout beat it to the finish line. - const connection = await Promise.race([pending, abort.then(() => undefined)]); - if (connection && !signal.aborted && !expired) { + const connected = await Promise.race([pending, abort.then(() => undefined)]); + if (connected && !signal.aborted && !expired) { + const [connection, dial] = connected; if (props.consume) forwardAnnounced(connection, props.consume); // Before the caller sees the session, so its first requests wait on these answers. hold(connection, dial, tokens); @@ -341,7 +341,7 @@ export async function connect({ url, ...props }: ConnectProps): Promise conn.abort()).catch(() => {}); + pending.then(([conn]) => conn.abort()).catch(() => {}); // The caller's abort is teardown and wins over the timeout. throw signal.aborted ? signal.reason : expired; } finally { @@ -361,32 +361,30 @@ function dialable(url: URL): URL { return new URL(`https:${url.href.slice(url.protocol.length)}`); } +/** Dial `raw`, resolving with the session and the credential the winning transport presented. */ async function connectInner( - dial: Credential, + raw: URL, + tokens: readonly string[], props: Omit, abort: Promise, -): Promise { +): Promise<[Established, Credential]> { + const offered = offer(props.versions); + const dial = credential(raw, tokens, offered.setupToken); const url = dialable(dial.url); - const wiring: SessionProps = { + const fallbackDial = props.websocket?.url && fallback(raw, props.websocket.url, tokens, offered.setupToken); + const wiring = (presented: Credential): SessionProps => ({ publish: props.publish, handle: props.auth, client: true, extensions: Extensions.offered(props.extensions), - token: dial.setup === undefined ? undefined : new TextEncoder().encode(dial.setup), + token: presented.setup === undefined ? undefined : new TextEncoder().encode(presented.setup), implementation: props.implementation ?? IMPLEMENTATION, - }; - // A fallback URL needs the credential wherever the primary one does. - const fallback = - props.websocket?.url && url.searchParams.get("jwt") && !props.websocket.url.searchParams.get("jwt") - ? withJwt(props.websocket.url, url.searchParams.get("jwt") as string) - : props.websocket?.url; - - const offered = offer(props.versions); + }); if (props.transport) { const transport = props.transport; void abort.then(() => transport.close()); - return connectTransport(url, transport, wiring, offered.setup); + return [await connectTransport(url, transport, wiring(dial), offered.setup), dial]; } // Stop transports after one connects or the caller aborts. @@ -403,7 +401,7 @@ async function connectInner( !webtransport || websocketWon.has(url.toString()) ? 0 : (props.websocket?.delay ?? DEFAULT_WEBSOCKET_DELAY_MS); const websocket = props.websocket?.enabled !== false - ? connectWebSocket(fallback ?? url, headstart, cancel, offered.alpns) + ? connectWebSocket(fallbackDial?.url ?? url, headstart, cancel, offered.alpns) : undefined; if (!websocket && !webtransport) { @@ -450,7 +448,8 @@ async function connectInner( session.close(); throw new Error("server negotiated no protocol, and no offered version negotiates in SETUP"); } - return await connectTransport(url, session as WebTransport, wiring, offered.setup); + const won = session instanceof Session && fallbackDial ? fallbackDial : dial; + return [await connectTransport(url, session as WebTransport, wiring(won), offered.setup), won]; } async function connectTransport( diff --git a/js/net/src/connection/tokens.test.ts b/js/net/src/connection/tokens.test.ts index c08732bb2db..4faa1c0b09d 100644 --- a/js/net/src/connection/tokens.test.ts +++ b/js/net/src/connection/tokens.test.ts @@ -6,7 +6,7 @@ import { Reader } from "../stream.ts"; import type { Established } from "./established.ts"; import { offered } from "./extensions.ts"; import { exchangeSetup } from "./handshake.ts"; -import { credential, extra, Held, hold } from "./tokens.ts"; +import { credential, extra, fallback, Held, hold } from "./tokens.ts"; const URL_ = new URL("https://relay.example.com/room"); @@ -32,6 +32,21 @@ test("a URL credential sends every other token on AUTH", () => { expect(extra(dial, ["a", "peer"])).toEqual(["a"]); }); +test("a WebSocket fallback presents the credential its own URL decides", () => { + const ws = new URL("wss://ws.example.com/room"); + // Its own `jwt` is its connection credential, so the configured token rides AUTH, not SETUP. + const own = fallback(URL_, new URL("wss://ws.example.com/room?jwt=b"), ["a"], true); + expect(own.setup).toBeUndefined(); + expect(extra(own, ["a"])).toEqual(["a"]); + // Without one it inherits the primary URL's, or else takes the configured tokens. + expect(fallback(new URL("https://relay.example.com/room?jwt=p"), ws, ["a"], true).url.href).toBe( + `${ws.href}?jwt=p`, + ); + const configured = fallback(URL_, ws, ["a"], false); + expect(configured.url.href).toBe(`${ws.href}?jwt=a`); + expect(configured.setup).toBe("a"); +}); + test("no tokens present nothing", () => { const dial = credential(URL_, [], false); expect(dial.url.href).toBe(URL_.href); diff --git a/js/net/src/connection/tokens.ts b/js/net/src/connection/tokens.ts index 610a6d8bb44..5728c525cae 100644 --- a/js/net/src/connection/tokens.ts +++ b/js/net/src/connection/tokens.ts @@ -28,6 +28,21 @@ export function credential(url: URL, tokens: readonly string[], setupToken: bool return { url: setupToken ? url : withJwt(url, first), setup: first, connection: first }; } +/** + * Decide what a dial to the WebSocket fallback's own URL presents. + * + * The fallback presents the credential its URL decides, never one taken from the primary: + * its own `jwt`, else the primary URL's `jwt`, else the configured tokens as {@link credential} + * decides them. + * + * @internal + */ +export function fallback(primary: URL, url: URL, tokens: readonly string[], setupToken: boolean): Credential { + const inherited = primary.searchParams.get("jwt"); + if (inherited && !url.searchParams.get("jwt")) return credential(withJwt(url, inherited), tokens, setupToken); + return credential(url, tokens, setupToken); +} + /** `url` with `jwt` appended to its query. @internal */ export function withJwt(url: URL, jwt: string): URL { const next = new URL(url); diff --git a/rs/moq-tokio/src/client.rs b/rs/moq-tokio/src/client.rs index 60de580bc8d..70dab5dab88 100644 --- a/rs/moq-tokio/src/client.rs +++ b/rs/moq-tokio/src/client.rs @@ -370,6 +370,11 @@ impl Client { Ok(session) } + /// What a dial to `url` presents of `tokens`, given the versions this client offers. + pub(crate) fn credential(&self, url: &Url, tokens: &crate::connect::Set) -> crate::connect::Credential { + crate::connect::Credential::new(url, &self.versions, tokens) + } + /// Dial the given URL and complete the MoQ handshake. /// /// Errors if no transport feature is compiled in. @@ -383,14 +388,14 @@ impl Client { pub(crate) async fn dial( &self, _addr: crate::connect::Addr, - _tokens: &crate::connect::Set, - ) -> crate::Result<(Dialed, crate::connect::Credential)> { + _credential: &crate::connect::Credential, + ) -> crate::Result { Err(Error::NoBackend( "no backend compiled; enable quic, iroh, websocket, tcp, or uds feature", )) } - /// Dial the given URL and complete the MoQ handshake. + /// Dial the given URL and complete the MoQ handshake, presenting `credential`. /// /// The scheme picks the transport, and `https://` races QUIC against the /// WebSocket fallback so a blocked UDP path still connects. The session's @@ -406,13 +411,12 @@ impl Client { pub(crate) async fn dial( &self, addr: crate::connect::Addr, - tokens: &crate::connect::Set, - ) -> crate::Result<(Dialed, crate::connect::Credential)> { - let credential = crate::connect::Credential::new(addr.url(), &self.versions, tokens); + credential: &crate::connect::Credential, + ) -> crate::Result { let addr = addr.with_url(credential.url.clone()); #[cfg(test)] if let Some(dialer) = &self.dialer { - return Ok((dialer(addr).await?, credential)); + return dialer(addr).await; } let client = match &credential.setup { Some(token) => { @@ -442,7 +446,7 @@ impl Client { }; tracing::info!(version = %dialed.session.version(), transport = %dialed.transport, "connected"); - Ok((dialed, credential)) + Ok(dialed) } /// The moq client builder, advertising `path` in the SETUP when there is one. @@ -1643,8 +1647,8 @@ mod tests { // `dial` rather than `connect`: this is about one attempt's deadline, and // `connect` now hands back a reconnect loop that would redial past it. - let tokens = Default::default(); - let mut attempt = Box::pin(client.dial(url.into(), &tokens)); + let credential = client.credential(&url, &Default::default()); + let mut attempt = Box::pin(client.dial(url.into(), &credential)); let _silent = tokio::select! { res = &mut attempt => match res { Err(err) => panic!("connect failed before the silent peer accepted it: {err}"), diff --git a/rs/moq-tokio/src/connect/token.rs b/rs/moq-tokio/src/connect/token.rs index be53ee9ff07..6fc9aff157c 100644 --- a/rs/moq-tokio/src/connect/token.rs +++ b/rs/moq-tokio/src/connect/token.rs @@ -95,6 +95,7 @@ impl Tokens { } /// What one dial presents, decided from its URL, the offered versions, and the tokens. +#[derive(Clone)] pub(crate) struct Credential { /// The URL to dial, carrying the connection credential in its query when an offered /// version has no SETUP token. diff --git a/rs/moq-tokio/src/connection.rs b/rs/moq-tokio/src/connection.rs index 714d1cf5f2c..431407e5ae7 100644 --- a/rs/moq-tokio/src/connection.rs +++ b/rs/moq-tokio/src/connection.rs @@ -1111,11 +1111,10 @@ impl Connection { // Read once per dial, so a refusal is judged against what this dial presented. let tokens = client.tokens().snapshot(); - match Self::dial_any(shared, &client, &addrs, &mut draining, &tokens).await { - Ok((addr, dialed, credential)) => { + match Self::dial_any(shared, &client, &addrs, &mut draining, &tokens, &held).await { + Ok((addr, dialed)) => { let url = addr.url().clone(); tracing::info!(peer = %Endpoint(&url), transport = %dialed.transport, "connected"); - held.dialed(credential); shared.connected(&dialed.session, dialed.transport); let session = dialed.session; @@ -1356,7 +1355,8 @@ impl Connection { addrs: &Addrs, draining: &mut Option, tokens: &crate::connect::Set, - ) -> crate::Result<(crate::connect::Addr, crate::client::Dialed, crate::connect::Credential)> { + held: &tokens::Holder, + ) -> crate::Result<(crate::connect::Addr, crate::client::Dialed)> { let candidates = addrs.as_slice(); let mut last = None; let mut transient = None; @@ -1365,7 +1365,12 @@ impl Connection { let url = addr.url(); tracing::info!(peer = %Endpoint(url), "connecting"); - let mut dial = std::pin::pin!(client.dial(addr.clone(), tokens)); + // Held before the dial starts its session's driver, so a request the session + // sends early waits on their answers rather than being refused outside the + // connection credential's grant. + let credential = client.credential(url, tokens); + held.dialed(&credential); + let mut dial = std::pin::pin!(client.dial(addr.clone(), &credential)); let dialed = kio::wait(|waiter| { if poll_draining(draining, waiter) { shared.disconnected(); @@ -1382,7 +1387,7 @@ impl Connection { }; match dialed { - Ok((dialed, credential)) => return Ok((addr.clone(), dialed, credential)), + Ok(dialed) => return Ok((addr.clone(), dialed)), // A status the peer actually sent is its answer, not this address's, so // unless it invites another attempt it settles the whole walk. Carrying // on would offer the same rejected credentials at the peer's other @@ -2676,6 +2681,10 @@ mod tests { } } + fn held(shared: &Shared, client: &Client) -> super::tokens::Holder { + super::tokens::Holder::new(shared.auth.clone(), client.tokens().consume()) + } + /// The first address that answers wins, however many dead ones precede it. /// This is what keeps a peer reachable when discovery advertises an interface /// that doesn't route from here ahead of the one that does. @@ -2692,9 +2701,16 @@ mod tests { let shared = shared(); let mut draining = None; - let (connected, _session, _) = tokio::time::timeout( + let (connected, _session) = tokio::time::timeout( WALK_TIMEOUT, - Connection::dial_any(&shared, &client, &addrs, &mut draining, &Default::default()), + Connection::dial_any( + &shared, + &client, + &addrs, + &mut draining, + &Default::default(), + &held(&shared, &client), + ), ) .await .expect("the walk must not hang") @@ -2716,7 +2732,14 @@ mod tests { let result = tokio::time::timeout( WALK_TIMEOUT, - Connection::dial_any(&shared, &client, &addrs, &mut draining, &Default::default()), + Connection::dial_any( + &shared, + &client, + &addrs, + &mut draining, + &Default::default(), + &held(&shared, &client), + ), ) .await .expect("the walk must not hang"); @@ -2744,7 +2767,16 @@ mod tests { let shared = shared(); let mut draining = None; // `Session` isn't `Debug`, so unwrap the failure by hand. - let Err(err) = Connection::dial_any(&shared, &client, &addrs, &mut draining, &Default::default()).await else { + let Err(err) = Connection::dial_any( + &shared, + &client, + &addrs, + &mut draining, + &Default::default(), + &held(&shared, &client), + ) + .await + else { panic!("a dead address must fail"); }; @@ -3541,6 +3573,107 @@ mod tests { } } + /// A configured token is held before its dial starts the session, so a broadcast + /// published before the dial, which only that token's grant covers, waits for its + /// answer instead of closing the session against the connection credential's grant. + #[cfg(feature = "tcp")] + #[tokio::test(start_paused = true)] + async fn a_publication_waits_for_a_configured_token() { + let shared = shared(); + let publish = crate::origin::spawn(); + let broadcast = publish.create_broadcast("room").unwrap(); + broadcast.announce(moq_net::origin::Route::default()).unwrap(); + let server_origin = crate::origin::spawn(); + + let config = crate::connect::Config { + tokens: vec!["connection".parse().unwrap(), "publish".parse().unwrap()], + ..Default::default() + }; + let mut client = config.init(Default::default()).expect("build client"); + let auth = shared.auth.clone(); + let (publish_dial, server_dial) = (publish.clone(), server_origin.clone()); + client.dialer = Some(std::sync::Arc::new(move |_addr: crate::connect::Addr| { + let (auth, publish, server_origin) = (auth.clone(), publish_dial.clone(), server_dial.clone()); + Box::pin(async move { + let versions = moq_net::Versions::from("moq-lite-07-wip".parse::().unwrap()); + let config = || { + let mut config = qmux::Config::new(qmux::Version::QMux01); + config.protocol = + qmux::Protocol::Negotiate(versions.alpns().iter().map(|alpn| alpn.to_string()).collect()); + config + }; + let (client, server) = tokio::io::duplex(64 * 1024); + let (client, server) = tokio::try_join!( + qmux::Session::connect(qmux::transport::Stream::new(client), config()), + qmux::Session::accept(qmux::transport::Stream::new(server), config()) + ) + .unwrap(); + let now = moq_net::time::Instant::now(); + let moq = moq_net::Client::new() + .with_versions(versions.clone()) + .with_auth(auth.clone()) + .with_publisher(&publish); + let client = moq.connect(now, client); + let server = async { + let mut request = moq_net::Server::new() + .with_versions(versions.clone()) + .with_subscriber(server_origin) + .accept_request(now, server) + .await + .unwrap(); + // The connection credential grants nothing; only the configured token + // covers the broadcast. + let mut requests = request.requests(); + let (session, driver) = request.ok().await.unwrap(); + tokio::spawn(moq_net::time::run(driver)); + tokio::spawn(async move { + let _session = session; + let mut issued = Vec::new(); + while let Some(request) = requests.next().await { + let grant = match request.token().is_empty() { + true => moq_net::auth::Grant::default(), + false => moq_net::auth::Grant::all(), + }; + issued.push(request.accept(grant)); + } + }); + }; + let client = async { + let (session, driver) = client.await.unwrap(); + tokio::spawn(moq_net::time::run(driver)); + session + }; + let (session, ()) = tokio::join!(client, server); + // Leave the session the time a multithreaded runtime may give it before the dial + // returns: on the paused clock, every exchange the session can make lands first, + // the connection credential's answer included. + tokio::time::sleep(Duration::from_millis(10)).await; + Ok(crate::client::Dialed { + session, + transport: crate::Transport::Tcp, + }) + }) + })); + + let addrs = Addrs::new("moqt://relay.example.com/".parse::().unwrap()); + let held = held(&shared, &client); + let tokens = client.tokens().snapshot(); + let (_, dialed) = Connection::dial_any(&shared, &client, &addrs, &mut None, &tokens, &held) + .await + .expect("the dial failed"); + + let mut announced = server_origin.consume().announced(); + let arrived = moq_clock::timeout(Duration::from_secs(5), announced.next()) + .await + .expect("the broadcast never reached the server") + .expect("the server's origin closed"); + assert!(matches!(arrived, moq_net::announce::Event::Start(update) if update.prefix.as_str() == "room")); + assert!( + futures::FutureExt::now_or_never(dialed.session.closed()).is_none(), + "the session closed before the configured token was answered" + ); + } + #[cfg(feature = "tcp")] const INITIAL: Duration = Duration::from_millis(100); #[cfg(feature = "tcp")] diff --git a/rs/moq-tokio/src/connection/tokens.rs b/rs/moq-tokio/src/connection/tokens.rs index 974aefab15e..b264d8cb626 100644 --- a/rs/moq-tokio/src/connection/tokens.rs +++ b/rs/moq-tokio/src/connection/tokens.rs @@ -54,13 +54,12 @@ impl Holder { } } - /// Hold the tokens `credential` leaves over. Call it before the dial's session is - /// published: the new tokens register before this returns, so a request the session - /// sends early waits on their answers rather than being refused outside the - /// connection credential's grant. - pub fn dialed(&self, credential: Credential) { + /// Hold the tokens `credential` leaves over. Call it before the dial that presents + /// `credential` starts: the new tokens register before this returns, so the session + /// presents them from its start. + pub fn dialed(&self, credential: &Credential) { let mut state = self.shared.state.lock().unwrap(); - state.credential = Some(credential); + state.credential = Some(credential.clone()); state.held.unsupported = false; state.update(); drop(state); @@ -283,7 +282,7 @@ mod tests { let versions = moq_net::Versions::from("moq-lite-07-wip".parse::().unwrap()); let credential = Credential::new(&url.parse().unwrap(), &versions, &tokens.snapshot()); let holder = Holder::new(pair.auth.clone(), tokens.consume()); - holder.dialed(credential); + holder.dialed(&credential); (tokens, holder) } From 206cfdd7ceceeb56f9439879ac8b02779e85b72c Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 07:54:00 -0700 Subject: [PATCH 41/45] fix(auth): address review on docs and the lite-07 credential test The relay test awaits lite-07's AUTH grant instead of waiting out a refusal, the Dart docs name the generated ConfigMoqException and Tokens.set_, and the relay auth doc says how to keep a configured token out of the URL. Co-Authored-By: Claude Opus 5.5 --- dart/moq/lib/src/client.dart | 6 ++--- dart/moq/lib/src/server.dart | 2 +- doc/bin/relay/auth.md | 5 +++- .../tests/integration/auth_lifetime.rs | 27 ++++++++++++++----- 4 files changed, 28 insertions(+), 12 deletions(-) diff --git a/dart/moq/lib/src/client.dart b/dart/moq/lib/src/client.dart index ca8c987a667..84961df6171 100644 --- a/dart/moq/lib/src/client.dart +++ b/dart/moq/lib/src/client.dart @@ -24,7 +24,7 @@ final class AnnounceOptions { /// the FFI's [MoqClientConfig]. /// /// Every field is optional; a null keeps the native default. A value the native -/// side cannot use fails [Moq.connect] with `MoqException.Config`. +/// side cannot use fails [Moq.connect] with `ConfigMoqException`. final class ConnectOptions { /// Local socket address to bind, e.g. `0.0.0.0:0`. final String? bind; @@ -259,12 +259,12 @@ final class Moq { /// The credentials every dial presents ([ConnectOptions.tokens]), to /// replace while running. /// - /// [Tokens.set] replaces them, such as before the current ones expire: the + /// [Tokens.set_] replaces them, such as before the current ones expire: the /// next dial presents them, and a live session with in-band AUTH presents /// them at once, withdrawing each replaced token once its replacements are /// answered, even when one is refused. After an authorization refusal the /// client redials only if they changed. An empty token throws - /// `MoqException.Config`. + /// `ConfigMoqException`. Tokens tokens() => _client.tokens(); /// Drain within one second, then stop the client. diff --git a/dart/moq/lib/src/server.dart b/dart/moq/lib/src/server.dart index c470e3f5877..e2eea51cf5a 100644 --- a/dart/moq/lib/src/server.dart +++ b/dart/moq/lib/src/server.dart @@ -6,7 +6,7 @@ import 'stream.dart'; /// Everything [Server.listen] can be told. /// /// A value the native side cannot use fails [Server.listen] with -/// `MoqException.Config`. +/// `ConfigMoqException`. final class ListenOptions { /// Local socket address to listen on, e.g. `127.0.0.1:4443` or `[::]:443`. final String bind; diff --git a/doc/bin/relay/auth.md b/doc/bin/relay/auth.md index a86b3bc26aa..b30b337a142 100644 --- a/doc/bin/relay/auth.md +++ b/doc/bin/relay/auth.md @@ -136,7 +136,10 @@ connection credential, sent in SETUP: moq-lite-07-wip's Token parameter, or moq-transport's `AUTHORIZATION TOKEN` option with Token Type 0. While the client also offers a version that cannot carry it (moq-lite before 07), it sends the same value as `?jwt=`, which `moq auth serve` admits since they match; two -different values are refused. Each further token is presented +different values are refused. A URL token can land in server and proxy access +logs, so to keep it out, offer only versions that carry it in SETUP, such as +`--connect-version moq-lite-07-wip`; a relay that predates SETUP tokens then +cannot receive it. Each further token is presented [in band](#in-band-renewal) and widens the session's grant. A URL that already carries a `?jwt=`, such as a cluster peer's, keeps it as the connection credential, and every configured token then rides in band. Dialing diff --git a/rs/moq-relay/tests/integration/auth_lifetime.rs b/rs/moq-relay/tests/integration/auth_lifetime.rs index b8c5aff97ff..d9302f1fa9e 100644 --- a/rs/moq-relay/tests/integration/auth_lifetime.rs +++ b/rs/moq-relay/tests/integration/auth_lifetime.rs @@ -531,13 +531,26 @@ async fn a_configured_token_is_the_connection_credential() { .await .expect("connect timeout") .expect("the configured token was refused"); - // A lite client is live before admission answers, so give a refusal time to land. - assert!( - tokio::time::timeout(Duration::from_millis(500), session.closed()) - .await - .is_err(), - "{versions:?}: the relay refused the configured token" - ); + if setup.is_some() { + // Lite-07 answers the SETUP token over AUTH, so its grant proves admission. + let mut grant = session.auth().grant(); + tokio::time::timeout(TIMEOUT, async { + while grant.peek().is_none() { + grant.changed().await.expect("the relay refused the configured token"); + } + }) + .await + .expect("grant timeout"); + } else { + // Lite-06 has no AUTH answer and a client is live before admission, so give a + // refusal time to land. + assert!( + tokio::time::timeout(Duration::from_millis(500), session.closed()) + .await + .is_err(), + "{versions:?}: the relay refused the configured token" + ); + } let seen = script.seen.lock().unwrap().clone(); let connect = seen.iter().find(|r| r.event == Event::Connect).expect("a connect"); From 0056878d183128669da40f1daa94ab4bed9c9ebf Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 15:51:06 -0700 Subject: [PATCH 42/45] fix(net): record the tokens connect props in the js/net API report main's API report check landed after this branch added ConnectProps.tokens and ConnectionProps.tokens, so the merge queue's build failed on the stale report. Also regenerates the Dart raw bindings against main's typed durations. Co-Authored-By: Claude Opus 5.5 --- dart/moq_ffi/lib/src/moq.dart | 150 ++++++++++++++++++++++++++++++++-- js/net/api/index.api.md | 2 + 2 files changed, 145 insertions(+), 7 deletions(-) diff --git a/dart/moq_ffi/lib/src/moq.dart b/dart/moq_ffi/lib/src/moq.dart index 87e4145ef83..5ad7f675d40 100644 --- a/dart/moq_ffi/lib/src/moq.dart +++ b/dart/moq_ffi/lib/src/moq.dart @@ -3409,6 +3409,7 @@ class FfiConverterMoqBackoff { class MoqClientConfig { final String? bind; final List versions; + final List tokens; final Duration? timeout; final Duration? race; final Duration? resolutionDelay; @@ -3423,6 +3424,7 @@ class MoqClientConfig { MoqClientConfig({ this.bind = null, this.versions = const [], + this.tokens = const [], this.timeout = null, this.race = null, this.resolutionDelay = null, @@ -3454,6 +3456,11 @@ class FfiConverterMoqClientConfig { ); final versions = versions_lifted.value; new_offset += versions_lifted.bytesRead; + final tokens_lifted = FfiConverterSequenceString.read( + Uint8List.view(buf.buffer, new_offset), + ); + final tokens = tokens_lifted.value; + new_offset += tokens_lifted.bytesRead; final timeout_lifted = FfiConverterOptionalDuration.read( Uint8List.view(buf.buffer, new_offset), ); @@ -3513,6 +3520,7 @@ class FfiConverterMoqClientConfig { MoqClientConfig( bind: bind, versions: versions, + tokens: tokens, timeout: timeout, race: race, resolutionDelay: resolutionDelay, @@ -3533,6 +3541,7 @@ class FfiConverterMoqClientConfig { final total_length = FfiConverterOptionalString.allocationSize(value.bind) + FfiConverterSequenceString.allocationSize(value.versions) + + FfiConverterSequenceString.allocationSize(value.tokens) + FfiConverterOptionalDuration.allocationSize(value.timeout) + FfiConverterOptionalDuration.allocationSize(value.race) + FfiConverterOptionalDuration.allocationSize(value.resolutionDelay) + @@ -3560,6 +3569,10 @@ class FfiConverterMoqClientConfig { value.versions, Uint8List.view(buf.buffer, new_offset), ); + new_offset += FfiConverterSequenceString.write( + value.tokens, + Uint8List.view(buf.buffer, new_offset), + ); new_offset += FfiConverterOptionalDuration.write( value.timeout, Uint8List.view(buf.buffer, new_offset), @@ -3610,6 +3623,7 @@ class FfiConverterMoqClientConfig { static int allocationSize(MoqClientConfig value) { return FfiConverterOptionalString.allocationSize(value.bind) + FfiConverterSequenceString.allocationSize(value.versions) + + FfiConverterSequenceString.allocationSize(value.tokens) + FfiConverterOptionalDuration.allocationSize(value.timeout) + FfiConverterOptionalDuration.allocationSize(value.race) + FfiConverterOptionalDuration.allocationSize(value.resolutionDelay) + @@ -13260,6 +13274,7 @@ class FfiConverterMoqServer { abstract class MoqClientInterface { void cancel(); UniffiRustFuture connect({required String url}); + MoqTokens tokens(); } final _MoqClientFinalizer = Finalizer>((ptr) { @@ -13323,6 +13338,17 @@ class MoqClient implements MoqClientInterface { moqExceptionErrorHandler, ); } + + MoqTokens tokens() { + return rustCallWithLifter( + (status) => uniffi_moq_ffi_fn_method_moqclient_tokens( + uniffiClonePointer(), + status, + ), + FfiConverterMoqTokens.lift, + null, + ); + } } class FfiConverterMoqClient { @@ -13546,6 +13572,78 @@ class FfiConverterMoqSession { } } +abstract class MoqTokensInterface { + void set_({required List tokens}); +} + +final _MoqTokensFinalizer = Finalizer>((ptr) { + rustCall((status) => uniffi_moq_ffi_fn_free_moqtokens(ptr, status)); +}); + +class MoqTokens implements MoqTokensInterface { + late final Pointer _ptr; + bool _isDisposed = false; + MoqTokens._(this._ptr) { + _MoqTokensFinalizer.attach(this, _ptr, detach: this); + } + factory MoqTokens.lift(Pointer ptr) { + return MoqTokens._(ptr); + } + Pointer uniffiClonePointer() { + if (_isDisposed) { + throw StateError("Object has already been disposed"); + } + return rustCall( + (status) => uniffi_moq_ffi_fn_clone_moqtokens(_ptr, status), + ); + } + + void dispose() { + if (_isDisposed) { + return; + } + _isDisposed = true; + _MoqTokensFinalizer.detach(this); + rustCall((status) => uniffi_moq_ffi_fn_free_moqtokens(_ptr, status)); + } + + void set_({required List tokens}) { + return rustCall((status) { + uniffi_moq_ffi_fn_method_moqtokens_set( + uniffiClonePointer(), + FfiConverterSequenceString.lower(tokens), + status, + ); + }, moqExceptionErrorHandler); + } +} + +class FfiConverterMoqTokens { + static MoqTokens lift(Pointer ptr) { + return MoqTokens.lift(ptr); + } + + static Pointer lower(MoqTokens value) { + return value.uniffiClonePointer(); + } + + static int allocationSize(MoqTokens value) { + return 8; + } + + static LiftRetVal read(Uint8List buf) { + final handle = buf.buffer.asByteData(buf.offsetInBytes).getInt64(0); + final pointer = Pointer.fromAddress(handle); + return LiftRetVal(MoqTokens.lift(pointer), 8); + } + + static int write(MoqTokens value, Uint8List buf) { + final handle = lower(value); + buf.buffer.asByteData(buf.offsetInBytes).setInt64(0, handle.address); + return 8; + } +} + abstract class MoqVideoConsumerInterface { void cancel(); UniffiRustFuture next(); @@ -14141,13 +14239,6 @@ class FfiConverterDuration { } static int write(Duration value, Uint8List buf) { - if (value.isNegative) { - throw ArgumentError.value( - value, - "value", - "Duration must not be negative", - ); - } final bytes = buf.buffer.asByteData(buf.offsetInBytes, 12); bytes.setUint64(0, value.inSeconds); final ms = (value.inMicroseconds - (value.inSeconds * 1000000)) * 1000; @@ -18800,6 +18891,14 @@ external Pointer uniffi_moq_ffi_fn_method_moqclient_connect( RustBuffer url, ); +@Native Function(Pointer, Pointer)>( + assetId: _uniffiAssetId, +) +external Pointer uniffi_moq_ffi_fn_method_moqclient_tokens( + Pointer ptr, + Pointer uniffiStatus, +); + @Native Function(Pointer, Pointer)>( assetId: _uniffiAssetId, ) @@ -18900,6 +18999,31 @@ external RustBuffer uniffi_moq_ffi_fn_method_moqsession_stats( Pointer uniffiStatus, ); +@Native Function(Pointer, Pointer)>( + assetId: _uniffiAssetId, +) +external Pointer uniffi_moq_ffi_fn_clone_moqtokens( + Pointer handle, + Pointer uniffiStatus, +); + +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external void uniffi_moq_ffi_fn_free_moqtokens( + Pointer handle, + Pointer uniffiStatus, +); + +@Native, RustBuffer, Pointer)>( + assetId: _uniffiAssetId, +) +external void uniffi_moq_ffi_fn_method_moqtokens_set( + Pointer ptr, + RustBuffer tokens, + Pointer uniffiStatus, +); + @Native Function(Pointer, Pointer)>( assetId: _uniffiAssetId, ) @@ -20012,6 +20136,9 @@ external int uniffi_moq_ffi_checksum_method_moqclient_cancel(); @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqclient_connect(); +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqclient_tokens(); + @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqsession_auth(); @@ -20045,6 +20172,9 @@ external int uniffi_moq_ffi_checksum_method_moqsession_state_changed(); @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqsession_stats(); +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqtokens_set(); + @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqvideoconsumer_cancel(); @@ -20744,6 +20874,9 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_method_moqclient_connect() != 5016) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } + if (uniffi_moq_ffi_checksum_method_moqclient_tokens() != 18102) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } if (uniffi_moq_ffi_checksum_method_moqsession_auth() != 1680) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } @@ -20777,6 +20910,9 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_method_moqsession_stats() != 36396) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } + if (uniffi_moq_ffi_checksum_method_moqtokens_set() != 58423) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } if (uniffi_moq_ffi_checksum_method_moqvideoconsumer_cancel() != 21122) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } diff --git a/js/net/api/index.api.md b/js/net/api/index.api.md index 1afd5d29b42..c7ba943531d 100644 --- a/js/net/api/index.api.md +++ b/js/net/api/index.api.md @@ -177,6 +177,7 @@ interface ConnectionProps { share?: boolean; signal?: never; timeout?: Milli; + tokens?: readonly string[] | Signal; transport?: never; url?: URL | Signal; versions?: readonly string[] | Signal; @@ -191,6 +192,7 @@ interface ConnectProps { publish?: Consumer_5; signal?: AbortSignal; timeout?: Milli; + tokens?: readonly string[]; transport?: WebTransport; url: URL; versions?: readonly string[]; From f42958f41bfda665bac8d22a0496a8cc815c9ff3 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 15:53:18 -0700 Subject: [PATCH 43/45] feat(c): moq_client_config.tokens The hand-written moq-c ships at this cut with parity for the m1 binding features, so it takes configured tokens like every other binding. Co-Authored-By: Claude Opus 5.5 --- doc/lib/c/index.md | 1 + rs/moq-c/src/api.rs | 9 +++++++++ rs/moq-c/src/client.rs | 8 ++++++++ rs/moq-c/src/test.rs | 21 +++++++++++++++++++++ 4 files changed, 39 insertions(+) diff --git a/doc/lib/c/index.md b/doc/lib/c/index.md index 4174047f9c0..9f5a5df0117 100644 --- a/doc/lib/c/index.md +++ b/doc/lib/c/index.md @@ -62,6 +62,7 @@ inside. The header is the reference: each function carries a doc comment. - **Handles and callbacks.** Every object is an integer handle, and every async result arrives on a `moq_status_callback` with your `void *user_data`. A status `> 0` is a live result, `0` a clean close, and `< 0` an error. The last two are terminal: moq-c never touches `user_data` again, so free it there. `*_close` and `*_cancel` only request shutdown; the terminal callback still fires. A session reports on a `moq_state_callback` instead: each `moq_connection_state` carries its `phase`, `connects`, the negative `failure` of the latest attempt, and the live `protocol`, and the `MOQ_CONNECTION_PHASE_CLOSED` snapshot is terminal, delivered after the handle is released. `moq_session_close()` drains finished tracks for dialed and accepted sessions; wait for that terminal snapshot to learn the result. - **Errors.** Negative return codes are named by `MOQ_ERROR_*`, and `moq_error()` gives the reason on the calling thread. Auth rejections have their own codes so you don't retry them. For the peer's protocol code use `moq_error_protocol()`; do not parse `moq_error()`. A panic in a binding background task reports `MOQ_ERROR_PANIC` through its terminal callback; unrelated objects keep running. A session closed because its credential expired has kind `MOQ_PROTOCOL_KIND_EXPIRED`, and a request refused for an expired token returns `MOQ_ERROR_EXPIRED`; refresh the token before retrying. A catalog that needs a newer moq-hang draft than this build reads returns `MOQ_ERROR_UNSUPPORTED_CATALOG`; upgrade the library to read it. A request-cap closure has kind `MOQ_PROTOCOL_KIND_TOO_MANY_REQUESTS`; reduce the workload or adjust the relay's [session limits](/bin/relay/config#session-and-peer) before reconnecting. +- **Tokens.** Set `moq_client_config.tokens` instead of putting the credential in the URL. The first is the connection credential, sent in SETUP and, while an offered version can't carry it there (lite before 07), as the URL's `?jwt=`; each other token widens the grant over AUTH. A `?jwt=` already in the URL stays the connection credential, which is how to reach a relay older than this release. To replace expiring tokens, close the session and connect again. - **Threading.** Call any function from any thread. Raw publish calls block until the codec takes the frame, which paces a publisher. - **Zeroed config is the default.** A zeroed `moq_client_config` means the default for every field, so new fields don't break callers. A field whose default isn't zero has a `has_*` flag: `backoff_timeout_us = 0` means "retry forever" only with `has_backoff_timeout = true`. - **Compressed tracks.** `moq_track_info.compress` on a raw track sends each group DEFLATE-compressed to opt-in `moq-lite-07-wip` peers; other peers, and every reader here, get the original frames. `@moq/net` cannot read a compressed track yet. diff --git a/rs/moq-c/src/api.rs b/rs/moq-c/src/api.rs index c5b2179a5b7..00a621501d7 100644 --- a/rs/moq-c/src/api.rs +++ b/rs/moq-c/src/api.rs @@ -1245,6 +1245,15 @@ pub struct moq_client_config { pub implementation: *const c_char, /// The length in bytes of `implementation`. pub implementation_len: usize, + + /// Credentials every dial presents, or NULL for none. The first is the connection + /// credential, sent in SETUP and, while an offered version can't carry it there (lite + /// before 07), as the URL's `?jwt=`; each other token widens the grant over in-band + /// AUTH. A `?jwt=` already in the URL stays the connection credential, and then every + /// token rides AUTH. An empty token fails the dial. + pub tokens: *const moq_string, + /// The number of entries in `tokens`. + pub tokens_len: usize, } /// The settings [moq_session_connect] dials with when given NULL. diff --git a/rs/moq-c/src/client.rs b/rs/moq-c/src/client.rs index 09a512b3850..0480c81f8bf 100644 --- a/rs/moq-c/src/client.rs +++ b/rs/moq-c/src/client.rs @@ -64,6 +64,14 @@ pub unsafe fn parse_client(config: Option<&moq_client_config>) -> Result>()?; } + out.connect.tokens = unsafe { ffi::parse_strings(config.tokens, config.tokens_len)? } + .into_iter() + .map(|token| { + token + .try_into() + .map_err(|err: moq_tokio::connect::EmptyToken| Error::InvalidConfig(err.to_string())) + }) + .collect::>()?; // Transport if let Some(bind) = unsafe { ffi::parse_str_optional(config.bind, config.bind_len)? } { diff --git a/rs/moq-c/src/test.rs b/rs/moq-c/src/test.rs index 5a0c5e9c29d..7b8ada14d08 100644 --- a/rs/moq-c/src/test.rs +++ b/rs/moq-c/src/test.rs @@ -5205,6 +5205,27 @@ fn implementation_prefixes_the_library() { assert!(unsafe { crate::parse_client(Some(&config)) }.is_err()); } +/// Configured tokens reach the dial in order, and an empty one fails it. +#[test] +fn tokens_reach_the_dial() { + assert!(parsed(&client_config()).connect.tokens.is_empty()); + + let mut config = client_config(); + let tokens = [moq_str("a"), moq_str("b")]; + config.tokens = tokens.as_ptr(); + config.tokens_len = tokens.len(); + let parsed = parsed(&config).connect.tokens; + assert_eq!( + parsed.iter().map(|token| token.as_str()).collect::>(), + ["a", "b"] + ); + + let empty = [moq_str("a"), moq_str("")]; + config.tokens = empty.as_ptr(); + config.tokens_len = empty.len(); + assert!(unsafe { crate::parse_client(Some(&config)) }.is_err()); +} + /// The whole point of the `has_*` flags: a caller who zeroes the struct and sets /// nothing must land on the defaults, not on zero. The backoff and the WebSocket /// fallback are the ones with non-zero defaults, so they are what would break. From 2a79fc4dcb1e197faad7f1ea2023ce7d50e01338 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 16:04:18 -0700 Subject: [PATCH 44/45] fix(net): no intra-doc link to the async-only Client from Version Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/version.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rs/moq-net/src/version.rs b/rs/moq-net/src/version.rs index c6fdf4e0981..28188688fe4 100644 --- a/rs/moq-net/src/version.rs +++ b/rs/moq-net/src/version.rs @@ -85,7 +85,7 @@ impl Version { } /// Whether the client's SETUP can carry its connection credential (see - /// [`Client::with_token`](crate::Client::with_token)). Every moq-transport draft can; + /// `Client::with_token`, behind the `async` feature). Every moq-transport draft can; /// moq-lite gained it in lite-07, so older versions need the credential in the URL. pub fn has_setup_token(self) -> bool { match self { From 71ca4c5dba79584400ecf3426e48fbb7dd4c2e25 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 16:43:56 -0700 Subject: [PATCH 45/45] fix(dart): regenerate the raw bindings with the pinned generator Co-Authored-By: Claude Opus 5.5 --- dart/moq_ffi/lib/src/moq.dart | 9 +++++++-- dart/moq_ffi/lib/src/uniffi_runtime.dart | 2 -- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/dart/moq_ffi/lib/src/moq.dart b/dart/moq_ffi/lib/src/moq.dart index 5ad7f675d40..49efb57cee0 100644 --- a/dart/moq_ffi/lib/src/moq.dart +++ b/dart/moq_ffi/lib/src/moq.dart @@ -8,9 +8,7 @@ import "dart:ffi"; import "dart:io" show Platform, File, Directory; import "dart:isolate"; import "dart:typed_data"; - import "package:ffi/ffi.dart"; - import "uniffi_runtime.dart"; export "uniffi_runtime.dart"; @@ -14239,6 +14237,13 @@ class FfiConverterDuration { } static int write(Duration value, Uint8List buf) { + if (value.isNegative) { + throw ArgumentError.value( + value, + "value", + "Duration must not be negative", + ); + } final bytes = buf.buffer.asByteData(buf.offsetInBytes, 12); bytes.setUint64(0, value.inSeconds); final ms = (value.inMicroseconds - (value.inSeconds * 1000000)) * 1000; diff --git a/dart/moq_ffi/lib/src/uniffi_runtime.dart b/dart/moq_ffi/lib/src/uniffi_runtime.dart index 9018eb4e97e..c61178325f7 100644 --- a/dart/moq_ffi/lib/src/uniffi_runtime.dart +++ b/dart/moq_ffi/lib/src/uniffi_runtime.dart @@ -8,7 +8,6 @@ import "dart:ffi"; import "dart:io" show Platform, File, Directory; import "dart:isolate"; import "dart:typed_data"; - import "package:ffi/ffi.dart"; class UniffiInternalError implements Exception { @@ -251,7 +250,6 @@ mixin FfiConverterPrimitive on FfiConverter { @override T lower(T value) => value; } - Uint8List createUint8ListFromInt(int value) { int length = value.bitLength ~/ 8 + 1; if (length != 4 && length != 8) {