feat(net): a token on a request authorizes that request - #4675
ksletmoe-aws wants to merge 49 commits into
Conversation
…meter Add a length-prefixed `Param for Bytes` so a request message can carry the AUTHORIZATION TOKEN parameter (0x03), and `token::decode_value`, which reads the same Token structure (section 8.9) the SETUP option carries. This is the foundation for the request-token quest: a token on SUBSCRIBE, REQUEST_UPDATE, PUBLISH, FETCH, PUBLISH_NAMESPACE, and the other params-bearing requests. The request-message decoders wire it in a following change, so decode_value carries the crate's own not-yet-wired marker until then. Co-Authored-By: Claude <noreply@anthropic.com>
A SUBSCRIBE the session grant does not cover falls back to an AUTHORIZATION TOKEN carried on the request itself (MoQ request-token). The token reaches the session's auth acceptor, tagged with the request's path and kind, and its grant covers only that request: it never joins the session union and ends when the request ends. - auth::Request gains optional per-request context, exposed as path(), kind() (auth::RequestKind), and token_kind(); a connection-credential token still has none. Handle::verify_request routes a request token through the same acceptor and returns a RequestVerdict whose grant() resolves to the acceptor's answer, or Unsupported when no requests() consumer verifies tokens. - Subscribe decodes and encodes the AUTHORIZATION TOKEN (0x03) in both decoder families: the strict draft-17+ message parameters and draft-14's trailing parameter block. - The publisher checks the session grant first, then the request token; a covering grant serves the subscription (gated on the request grant's life, not the session union), an uncovered or refused token is UNAUTHORIZED, and no consumer is NOT_SUPPORTED, via error::request::to_code. Co-Authored-By: Claude <noreply@anthropic.com>
SubscribeUpdate (the REQUEST_UPDATE message) gains an authorization_token field, decoded and encoded in both families (draft-14's trailing parameter block and the draft-15+ / draft-17+ message parameters), so a peer can present a fresh token to refresh a request's grant. The publisher's use of it (reading REQUEST_UPDATE off the subscribe stream and replacing the request grant) lands in a following change. Co-Authored-By: Claude <noreply@anthropic.com>
…is revoked A SUBSCRIBE authorized by a request token now holds a RequestGrant for the subscription's life: it carries the acceptor's grant and a deadline armed at the grant's expiry, and ends the request (never the session) when the deadline lapses (UNAUTHORIZED), the acceptor revokes it, or the acceptor drops the issued grant. The deadline is the existing crate::runtime::Deadline, re-armed when the acceptor replaces the grant. REQUEST_UPDATE renewal is prepared (RequestVerdict::poll_reply, RequestGrant:: renew) and unit-tested, but the publisher does not yet read REQUEST_UPDATE off the subscribe stream, so renew()/grant() carry the crate's not-yet-wired marker until that reader lands next. Tests (auth seam, one legacy + one strict draft where wire-relevant): a_request_grant_has_the_acceptors_expiry, a_renewal_token_replaces_the_grant_and _expiry, the_deadline_ends_the_request_unauthorized_without_renewal, a_refused_renewal_keeps_the_old_grant_until_it_lapses, an_acceptor_revoke_ends _the_request, each asserting the session grant is untouched. Co-Authored-By: Claude <noreply@anthropic.com>
After SUBSCRIBE_OK the publisher's serve loop now reads control messages off the subscribe stream. A REQUEST_UPDATE (SUBSCRIBE_UPDATE, 0x02) carrying a fresh AUTHORIZATION TOKEN is re-verified through the session's acceptor and, when its grant still covers the request, replaces the request grant and re-arms the deadline, acknowledged with REQUEST_OK. A refused, uncovered, or malformed renewal keeps the old grant and answers the update UNAUTHORIZED without tearing down the subscription, so the request ends only when the old grant lapses. A token-less update is the ordinary priority/forward change and is left untouched. The read future borrows only the reader and lives in an inner block, so that borrow is released before a renewal answers on the writer. RequestGrant::renew is now wired, so its dead_code marker is gone. Two publisher-level tests drive the reader end to end against the deadline: a_request_update_renews_a_token_subscription_past_the_old_expiry and a_refused_request_update_lets_the_old_grant_lapse. Co-Authored-By: Claude <noreply@anthropic.com>
A peer that announces a namespace the session grant does not cover may present an AUTHORIZATION TOKEN on the PUBLISH_NAMESPACE, and the subscriber verifies it through the same acceptor a session token reaches, scoped to that one announce (MoQ request-token). This is the ingest-side vertical: an encoder holds a PUBLISH_NAMESPACE open and renews its credential in-session. The change is additive. With no token, or a grant that already covers the path, behavior is unchanged and the origin model decides scope as before. Only an uncovered announce that carries a token takes the new path: verify_request with RequestKind::PublishNamespace; an accepted grant covering the path admits the announce with a RequestGrant lifetime (deadline plus acceptor revoke, ending the announce and never the session); a refused, uncovered, or malformed token answers request-level UNAUTHORIZED without teardown; no consumer is NOT_SUPPORTED. A REQUEST_UPDATE on the announce stream carrying a fresh token renews the grant and re-arms the deadline (REQUEST_OK); a refused renewal keeps the old grant until it lapses. The update loop now reads via the shared read_control and polls the grant lifetime alongside it. Wire: the AUTHORIZATION TOKEN parameter is already defined on PUBLISH_NAMESPACE in every supported draft (draft-17 section 9.3.2 / draft-18+ section 10.2.2); nothing new goes on the wire. PublishNamespace and PublishNamespaceUpdate now carry it on both decoder families, with legacy and strict round-trip tests, plus subscriber-level accept, refuse, and renew-past-expiry tests. Co-Authored-By: Claude <noreply@anthropic.com>
…ATUS, SUBSCRIBE_NAMESPACE A moq-transport peer may carry the AUTHORIZATION TOKEN parameter (0x03) on any of these requests. The strict draft-15+ decoders rejected the whole message on the unknown key, failing the session; the legacy draft-14 block already tolerated it. The strict decoders now consume the parameter so a request-token peer is not decode-killed. The token is dropped rather than acted on: FETCH, TRACK_STATUS, and PUBLISH are refused for other reasons (unsupported or joining-only), and SUBSCRIBE_NAMESPACE scopes through the origin, so none of them authorize by a request token yet. Only SUBSCRIBE and PUBLISH_NAMESPACE do. Nothing new goes on the wire; the parameter is already defined on these messages in every supported draft. A legacy and a strict decode test per message assert the token is accepted. Co-Authored-By: Claude <noreply@anthropic.com>
The strict draft-17+ message-parameter decoder threw on any unknown key, so a moq-transport peer presenting an AUTHORIZATION TOKEN (0x03) on a request would fail the whole message; the legacy count-prefixed form already tolerated it. js/net now recognizes 0x03 as a bytes parameter, so the token is decoded and dropped across SUBSCRIBE, FETCH, PUBLISH, TRACK_STATUS, PUBLISH_NAMESPACE and SUBSCRIBE_NAMESPACE. There is no accept-side consumer API in js/net (scoped out, PR question ii), so the token is not verified: an uncovered request is still refused by the session grant as before. Mirrors rs/moq-net. Co-Authored-By: Claude <noreply@anthropic.com>
Document that the AUTHORIZATION TOKEN may ride an individual request, not only the SETUP: session grant first, then the request's token, else UNAUTHORIZED; the token's grant covers only its request and ends with it; a REQUEST_UPDATE refreshes it in place. The relay refuses a token-bearing request it cannot verify (NOT_SUPPORTED with no acceptor); wiring a per-request lease into the --auth-url server is flagged as a follow-up. Co-Authored-By: Claude <noreply@anthropic.com>
|
Solid request-token vertical: Blocking
Non-blocking
VerdictITERATE This is an automated review, not the maintainer's decision |
kixelated
left a comment
There was a problem hiding this comment.
Automated review by review (OpenAI)
Reviewed commit: 928d4a52f30b063e9c73a9158f77bd777d58c936
Reusing the acceptor is a sensible direction, but the authorization boundaries and renewal lifecycle need the five fixes below. A shared request guard owning presenter-facing scope, local ceiling, grant updates and cancellation would simplify the duplicated state machines.
I also independently confirmed the mixed cluster/token-update and legacy renewal-response routing issues in the existing review comment, so have not duplicated them inline.
Verification: static review of the exact-head diff, surrounding call paths and IETF update rules. No builds or tests were run.
| match verdict.grant().await { | ||
| // The token's grant must cover this exact request; it authorizes nothing | ||
| // else and never joins the session union. | ||
| Ok(grant) if grant.publish.matches(msg.track_namespace.as_str()) => { |
There was a problem hiding this comment.
[P1] Check request grants from the presenter's perspective
Request::accept grants the presenting peer's permissions: ietf/auth.rs:482–485 sends its fields unchanged as AuthOk. An incoming SUBSCRIBE therefore needs grant.subscribe, not grant.publish. On a no-AUTH session, a publish-only credential currently gains read access, while a subscribe-only credential is refused. PUBLISH_NAMESPACE checks the inverse wrong field at subscriber.rs:1118. Correct both initial and renewal checks, and add asymmetric read-only/write-only grant tests.
| if let Some(token) = &msg.authorization_token | ||
| && !self | ||
| .auth | ||
| .covers(crate::auth::Direction::Publish, msg.track_namespace.as_str()) | ||
| { |
There was a problem hiding this comment.
[P1] Preserve the local authorization ceiling on the token path
With an origin serving room, call auth.authorize(Grant::default()), then accept a request token with Grant::all(): this branch still serves the SUBSCRIBE and leaves gate unset. A later local narrowing also cannot stop it. The received union and the local limit are separate (auth.rs:178–187); a request token may replace the former, not bypass the latter. Keep within_limit checks and limit-change monitoring independently. The outgoing token-based permitted/Gate bypasses need the same separation.
| Poll::Ready(Reply::Grant(grant)) => { | ||
| self.deadline.set(grant.expires); | ||
| self.grant = grant; | ||
| // Re-poll: the new deadline may already have lapsed, or another reply may | ||
| // be waiting. | ||
| continue; |
There was a problem hiding this comment.
[P1] Recheck request coverage after an Issued grant update
After accepting a token-authorized subscription or namespace with Grant::all(), calling issued.update(Grant::default()) should remove its access. Here the replacement only updates the stored grant/deadline; neither caller rechecks the new patterns. Because the empty grant has no expiry, poll_ended remains pending and the request continues indefinitely. Retain the request path and required permission in this guard and end it whenever a replacement no longer covers them.
| .encode(&ietf::SubscribeUpdate { | ||
| request_id, | ||
| subscription_request_id, | ||
| start_location: ietf::Location { group: 0, object: 0 }, | ||
| end_group: 0, |
There was a problem hiding this comment.
[P2] Preserve the subscription range when refreshing its token
These zero locations are not a token-only update. On draft-14, a live SUBSCRIBE with Largest Object {10,5} starts at {10,6}; renewal resets it to {0,0}, decreasing the start and requiring a peer to close the session with PROTOCOL_VIOLATION (draft-14 §9.10). On newer drafts, SubscribeUpdate::encode_msg always sends Filter::NextObject, replacing an absolute/ranged filter. Preserve the established range on draft-14 and omit unchanged filter/forward/priority parameters where the draft permits.
| if let Some(rg) = token_grant.as_mut() | ||
| && let Poll::Ready(err) = rg.poll_ended(waiter) | ||
| { | ||
| return Poll::Ready(Ren::Ended(err)); | ||
| } | ||
| verdict.poll_grant(waiter).map(Ren::Renewal) |
There was a problem hiding this comment.
[P2] Observe request cancellation while renewal verification is pending
Accept a PUBLISH_NAMESPACE with a non-expiring grant, send a renewal, leave its verifier pending, then FIN/reset the request stream. This wait polls only the old grant and new verdict, so the route stays attached indefinitely and the original Issued::closed() never resolves. The subscribe renewal wait has the same omission (publisher.rs:768–789). Race verification against request-stream closure too; an expiry deadline does not bound this when expires is None.
|
Maintainer decisions on the four questions: Q1. Request-token credential: ✅ Option 3, through Q2. Declining Auth and Solicit: ✅ a positive extensions declaration instead of Q3. Client-side live renewal: ✅ keep it in this PR, via the Q4. EXPIRED / MALFORMED_AUTH_TOKEN: ✅ later, with the Please record these in the quest Plan when you rework. Thanks! (Written by Claude Opus 5.5) |
…e deadline Follow-ups on the request-token path. Close the session on an alias request token: a request token whose structure is DELETE/USE_ALIAS decodes to Error::ProtocolViolation (nothing can be registered before SETUP). Every request call site downgraded it to a per-request UNAUTHORIZED, so a connection-level violation left the session alive. It now closes the session exactly as the SETUP path does; a merely-undecodable structure is still refused per request without tearing down the connection. Publisher SUBSCRIBE + renewal and subscriber PUBLISH_NAMESPACE + renewal all propagate it. Test per side: an_alias_token_on_a_request_is_a_protocol_violation. Race renewal against the deadline: the renewal verify was a bare `verdict.grant().await` outside the kio::wait, so a slow acceptor stalled serving and, worse, let a request outlive its grant because the old deadline was not polled. The verify is now a pending verdict polled inside the serve loop alongside serving and the old grant's deadline (RequestVerdict::poll_grant); the loop pauses reading until it resolves, and the deadline still fires if the acceptor hangs. Test: a_slow_renewal_does_not_stall_serving. Propagate publisher renewal write failures: the publisher renewal answers now propagate a write failure with `?`, as the subscriber already did, instead of swallowing it. Co-Authored-By: Claude <noreply@anthropic.com>
… pending) The "On a request" section described relay verification through admissions() that this change does not deliver. Scope it to the moq-net library seam (auth::Handle::requests(), auth::Request::path()/kind()) and state that moq-relay does not yet opt in and refuses a request token NOT_SUPPORTED until the per-request lease (part B) lands. Co-Authored-By: Claude <noreply@anthropic.com>
A quest Goal violation. `Handle::requests()` closed the acceptor queue when the session did not negotiate the moq-dev AUTH extension, and `unsupported()` closed it when the peer turned out not to, so `verify_request` refused every request token on a standard IETF session. A request-borne token rides the request message, not the AUTH stream, so it must be verified without the extension. Both were coupling the standard 0x03 request-token path to a moq-dev extension, refusing exactly the non-moq-dev peer (a standard moq-transport publisher or CDN) the quest exists to serve. The request queue now stays live regardless of `supported`; only session-token presentation (`add`) stays Unsupported without the extension. Test: a_request_token_is_verified_without_the_auth_extension. Co-Authored-By: Claude <noreply@anthropic.com>
The send side was hardcoded off: the outgoing PUBLISH_NAMESPACE (advertise) and SUBSCRIBE both set authorization_token: None, so no moq-dev client could exercise the server path and interop rested on an untested assumption. Add a client-side setter following the existing builder style: Client::with_request_token(token) threads an AUTHORIZATION TOKEN through ietf::Config to the Publisher and Subscriber, which attach it to the PUBLISH_NAMESPACE they advertise, its REQUEST_UPDATE (so a reprice also refreshes the credential), and the SUBSCRIBE they send. A server presents none. Additive: with no token set, every request carries authorization_token: None byte-for-byte as before. Test: a_configured_request_token_rides_the_publish_namespace (legacy draft-14 + strict draft-18). The API surface is called out in the PR body for review; JS client-send parity is a listed follow-up (the end-to-end verification is Rust-driven). Co-Authored-By: Claude <noreply@anthropic.com>
`with_request_token` existed only on `moq_net::Client`, but moq-cli, the test publisher, and every high-level tool go through `moq_tokio::Client`, which wraps `moq_net::Client` and exposed no way to set it. So no real tooling could present a request token and the no-extension peer case could not be exercised end to end. Add `moq_tokio::Client::with_request_token(impl Into<Bytes>)` next to with_peer_hop, delegating to the inner `moq_net::Client`. The wire round-trip is proven at the moq-net layer (a_configured_request_token_rides_the_publish_namespace); moq-tokio has no in-process wire harness and building a Client needs a transport feature, so the moq-tokio test is a compile-level builder-signature check. A moq-tokio connect::Config / moq-cli --request-token knob (parsing bytes-or-file into a Token structure) is more than a few lines and is listed in the PR Follow-ups. Co-Authored-By: Claude <noreply@anthropic.com>
…ust when it forbids The token gate was `token.is_some() && !allows(dir, path)`, but `allows` returns true on a `None` union (its permissive default). A session that never negotiated the AUTH extension has a `None` union forever, so `!allows` was always false: the 0x03 token was decoded and IGNORED, and the request admitted by the default rather than by the token. That is exactly the non-moq-dev peer (a standard moq-transport encoder or CDN) the quest exists to serve. Add `Handle::covers(dir, path)`, which (unlike `allows`) treats a `None` union as NOT covering, and gate the token path on it: a token-bearing request is verified whenever the union does not positively cover the path, so a no-AUTH session's token is checked, not shadowed. The token-less path keeps `allows` unchanged, so the additive constraint holds. A refused token on a no-AUTH session is now a request-level UNAUTHORIZED, not a default admit. The no-extension acceptor-seam test exercises the acceptor directly; the new tests go through the message gate on a `Handle::new(false)` session: a_request_token_on_a_no_auth_session_is_verified (publisher SUBSCRIBE, subscriber PUBLISH_NAMESPACE; admit on grant, refuse on refusal) and a_token_less_request_on_a_no_auth_session_is_unchanged. Co-Authored-By: Claude <noreply@anthropic.com>
… draft It was unclear whether the AUTHORIZATION TOKEN (0x03) is emitted on PUBLISH_NAMESPACE at draft-17+ or diverts to an AUTH session stream, and whether draft-16 carries it at all. Extend the two existing tests across draft-14..18: the message round-trip (encode then decode preserves the token) and the client emission (a configured token appears on the advertise stream). Both pass at 14, 15, 16, 17, and 18, so the token rides the request param on every supported draft; `with_request_token` sets only the message field and opens no AUTH stream. Co-Authored-By: Claude <noreply@anthropic.com>
…st tokens moq_tokio::server::Request exposed token() but no auth(), so a QUIC app could only reach the acceptor via Session::auth() AFTER ok(). ok() starts the session driver, whose first poll fixes who answers request tokens, so a consumer installed afterwards races it nondeterministically: the driver wins and a valid token is refused Unsupported, or the app wins and the grant handshake stalls. Admit-on-grant could not be driven deterministically on any moq-tokio QUIC server. Add Request::auth() -> moq_net::auth::Handle, mirroring token() and delegating to the moq-net handshake's auth(), documented to take requests() before ok(). Same completeness argument as exposing with_request_token on the wrapper: the acceptor seam must reach the moq-tokio surface. Co-Authored-By: Claude <noreply@anthropic.com>
A QUIC harness could take Request::auth().requests() before ok() yet see the client's token-bearing request refused Unsupported (the default acceptor). Pin the contract through the REAL accept path (accept_request -> ok): install the acceptor before ok(), then verify a request token on the session's own auth() handle (the one the driver polls) and assert the pre-ok consumer receives it and granting admits. Passes at draft-14 (a no-AUTH-extension legacy session) and draft-18 (the modern uni-SETUP path), so the moq-net handle is shared end to end; a harness that sees otherwise differs in when or on which handle it takes the requests. Co-Authored-By: Claude <noreply@anthropic.com>
…rant enforcement On the dialing side, enforce_grant closes the session when the client announces a broadcast outside its connection grant. A client that also presents a request token authorizes each of its own requests at the server per-request (the covers-gate plus the app's acceptor), so the connection grant does not bound them: the token is precisely how it publishes outside that grant. Enforcing the grant locally would close the client for exactly the announce the token was meant to carry, before the server ever saw it, so a request-borne token could never authorize at draft-17+ when a connection credential was also present. Stand enforcement down when a request token is configured; the server still refuses a bad token per request. Co-Authored-By: Claude <noreply@anthropic.com>
…er and publisher ietf::start built the draft 14-16 Publisher and Subscriber without .with_auth(auth), so on a session that never negotiated the MoQ Auth extension the driver consulted a fresh default auth Handle instead of the session handle a requests() acceptor was installed on. A request-borne AUTHORIZATION TOKEN then reached verify_request on a handle whose acceptor was Undecided, which resolves to Default and refuses the request NOT_SUPPORTED, so the announce never reached the origin. The modern (17+) branch already wired .with_auth, so only the legacy no-extension path (the non-moq-dev peer shape the quest targets) was affected, and the existing unit tests missed it by constructing the Subscriber with .with_auth by hand. Add a driver-level integration test that drives a real client and server over the mock transport at draft-14: a request token on a PUBLISH_NAMESPACE must reach the pre-ok requests() acceptor and admit the announce. It fails before this fix and passes after. Co-Authored-By: Claude <noreply@anthropic.com>
A client presenting an AUTHORIZATION TOKEN (MoQ request-token) could set it only at connect time. Add Client::set_request_token so a running session can present a fresh credential, and re-present it on every live request as a REQUEST_UPDATE, keeping a token-authorized request alive past its old grant's expiry without reconnecting. The token becomes a shared, watchable RequestToken cell (kio::Shared) threaded to the publisher and subscriber. On change, the publisher loop re-presents it on each live announce as a token-only PUBLISH_NAMESPACE_UPDATE (draft-17+, where that message exists), and the subscriber loop re-presents it on each live subscription as a token-only SUBSCRIBE_UPDATE (draft-14 on). An unchanged token is a no-op, and a client that presents no token is byte-identical to before. The subscribe reader now consumes the REQUEST_OK / REQUEST_ERROR answer to a renewal it sent, so a renewal does not read as an unexpected message. Mirrored on moq_tokio::Client::set_request_token. Co-Authored-By: Claude <noreply@anthropic.com>
…ection grant
A request token could authorize a request the session grant did not cover only on
draft-14, where no connection credential sets a union. At draft-17+ the client
filtered its own request against its connection grant before the wire, so the
token never got the chance the quest's goal names ("present or refresh a
credential on SUBSCRIBE, REQUEST_UPDATE, PUBLISH_NAMESPACE").
A token-bearing client no longer self-censors on its connection grant; the
server's covers-gate is the authority and refuses a bad token per request. When a
request token is set, the publisher's announce filter (permitted) stands down, and
the subscriber's allows() gate and its shrink-revoke Gate stand down. Behavior is
unchanged when no token is set.
The control-stream adapter now routes a SUBSCRIBE_UPDATE follow-up by the
subscription's Request ID (its second field at draft-14/15/16), not the update's
own, so a renewal reaches the subscription it renews at draft-14 exactly as at
draft-18.
Co-Authored-By: Claude <noreply@anthropic.com>
Add Client::without_auth_extension (mirrored on moq-tokio), so a moq-dev client can connect as a peer that does not negotiate the moq-dev AUTH Setup Option, emulating a standard moq-transport peer (an encoder or CDN). When set, the client omits the option from its SETUP and builds its auth handle unsupported, so it presents no connection credential and the server sees declared.auth false. This lets a request-borne AUTHORIZATION TOKEN be exercised as the authorizing artifact at draft-17+: on an ungranted (None-union) session the server's covers-gate does not short-circuit, where a negotiated connection grant would cover the request and skip the token. Additive: the default still declares the extension, and a version that does not negotiate it is unaffected. The driver-level test a_client_may_decline_the_auth_extension proves it at draft-18: the declining client holds no session grant, its token-bearing SUBSCRIBE reaches the acceptor, and a token-less request is admitted by the permissive default. Co-Authored-By: Claude <noreply@anthropic.com>
Add Server::without_solicit (mirrored on moq-tokio), so a server can stop declaring the MoQ Solicit Setup Option. A peer that speaks the extension then sends an unsolicited PUBLISH_NAMESPACE (the base moq-transport behavior) instead of answering our SUBSCRIBE_NAMESPACE inline. This is what lets a request-borne AUTHORIZATION TOKEN ride an announce to a moq-net server. Only an unsolicited PUBLISH_NAMESPACE carries the token; the inline Namespace entry has no parameter slot for one. A standard moq-transport peer (a non-moq-dev encoder or CDN) never reads Solicit and always announces unsolicited, so declining it emulates that shape from a moq-dev client for interop testing. The default still declares Solicit. The receive half is gated to match: an unsolicited PUBLISH_NAMESPACE from a solicit-aware peer is a protocol violation only when we actually declared Solicit (Subscriber::with_solicit), so a server that declined it accepts the announce it invited rather than faulting the session. The draft-18 test a_server_that_declines_solicit_gets_a_token_bearing_unsolicited_announce proves it in the launch shape: server without Solicit plus client without the AUTH extension, the client's request token rides an unsolicited PUBLISH_NAMESPACE and reaches the acceptor; with Solicit declared (the default) the client answers inline, carries no token, and the acceptor is never consulted. Co-Authored-By: Claude <noreply@anthropic.com>
cargo fmt --all over the files this branch touched; no code change. Base ea7e984 is fmt-clean, so these were introduced by the branch. Co-Authored-By: Claude <noreply@anthropic.com>
Re-threading only, no behavior change, after rebasing onto upstream/quest/m1/auth/README (51c19e2). Three sites moved under us: - `Issue::shared()` -> `kio::Shared::<Issue>::default()`: the auth children made `Issue` a plain `#[derive(Default)]` struct with no `shared` ctor; the acceptor seam constructs the shared cell directly, as `Serving::new` does. - `Handle::covers` maps `Direction` to `Grant.publish` / `Grant.subscribe`: the union is a `Grant` with named pattern fields now, not a `patterns(dir)` method, mirroring `State::parts`. - thread `request_token` / `solicit` into the upstream session test's `ietf::session::Config` literal, which gained those required fields. Co-Authored-By: Claude <noreply@anthropic.com>
A token-bearing client stood down both its announce self-censor and dialing-side enforce_grant whenever a request token was set. That is right only when the announce carries the token, which is when it rides its own PUBLISH_NAMESPACE request. A peer that requires MoQ Solicit gets inline NAMESPACE entries instead, which have no token slot, so a client holding a token for SUBSCRIBE could advertise outside its connection grant with nothing for the server to verify. The announce filter now stands down only for PUBLISH_NAMESPACE targets, and enforce_grant only when announces ride requests: draft-14/15, or a later draft whose peer does not require solicitation. Otherwise the connection grant bounds what is published, as without a token. Tests: a_request_token_does_not_lift_the_grant_on_inline_namespaces (publisher, inline answer to SUBSCRIBE_NAMESPACE) and a_request_token_does_not_lift_the_grant_when_announces_are_inline (enforce_grant against a solicit-requiring peer). Co-Authored-By: Claude <noreply@anthropic.com>
A request token's grant is the presenting peer's permissions: the acceptor's Grant goes back unchanged, so its publish patterns name what that peer may publish and its subscribe patterns what it may read. The SUBSCRIBE path checked grant.publish and the PUBLISH_NAMESPACE path grant.subscribe, inverted on both the initial check and the renewal, so a publish-only credential gained read access and a subscribe-only one was refused. RequestKind::covers now reads the field the request needs, and both verticals use it. The request guard also forgot what it was guarding. An acceptor-side Issued::update replaced the stored grant and deadline without rechecking it, so updating to a grant that no longer covered the request (one with no expiry, say) left it running indefinitely. RequestGrant now keeps the request's path and kind, ends the request when a replacement stops covering it, and answers renewals with the same check. Tests: request_coverage_is_from_the_presenters_side, an_update_that_stops_covering_ends_the_request, a_subscribe_token_needs_a_subscribe_grant and an_announce_token_needs_a_publish_grant (read-only and write-only grants on each side). Co-Authored-By: Claude <noreply@anthropic.com>
A request token may replace the received union, never the limit this side set on the peer with Handle::authorize; the two are separate (State::parts). The token path bypassed both: with a limit of nothing, a token the acceptor granted everything still served a SUBSCRIBE, left it ungated, and no later narrowing could end it. The outgoing bypasses had the same shape: a token-bearing client's announce filter and its SUBSCRIBE allows() gate skipped the limit along with the grant. The incoming SUBSCRIBE token path now refuses a request outside the limit and holds a limit-only Gate (Gate::limit) for the subscription's life, so a narrowing ends it. A token-bearing client still filters its announces and SUBSCRIBEs on the limit, with the same limit-only gate on the SUBSCRIBE. The incoming PUBLISH_NAMESPACE path already applied the limit independently of the token. Tests: a_request_token_cannot_exceed_the_local_limit (refused up front, ended on narrowing) and a_request_token_does_not_lift_the_local_limit_on_announces. Co-Authored-By: Claude <noreply@anthropic.com>
A request-token renewal sent a SUBSCRIBE_UPDATE that was not token-only.
On draft-14 its fixed fields reset the start to {0, 0} and the end to
open: a live subscription starting at Largest Object + 1 saw its start
decrease, which draft-14 section 9.10 requires a peer to treat as a
PROTOCOL_VIOLATION. On draft-15+ SubscribeUpdate::encode_msg always sent
Filter::NextObject plus forward and priority, replacing an absolute or
ranged filter.
SubscribeUpdate's priority, forward flag and filter are now optional,
and an omitted one is left off the wire so the update keeps its value.
A renewal on draft-15+ carries only the token; on draft-14, which has
no way to omit them, it restates the subscription's own start (the
object after SUBSCRIBE_OK's Largest Object) and priority.
Test: setting_a_new_request_token_re_presents_it_on_a_live_subscription
now asserts the exact renewal on draft-14 and draft-18.
Co-Authored-By: Claude <noreply@anthropic.com>
While a request-token renewal was being verified, both serve loops polled only the old grant's deadline and the pending verdict. A peer that then finished or reset the request stream went unnoticed, and with a grant that never expires nothing else could end the request: the route stayed attached and the original Issued::closed never resolved. Both the subscribe loop and the announce update loop now watch the stream as well. A FIN or reset ends the request; a message that arrives meanwhile waits for the verdict, except a PUBLISH_NAMESPACE_DONE, which withdraws the announce at once. read_control now decodes a control message as one value, so a read dropped part-way through when the verdict resolves consumes nothing. Tests: a_closed_subscription_ends_while_a_renewal_is_pending and a_closed_announce_ends_while_a_renewal_is_pending. Co-Authored-By: Claude <noreply@anthropic.com>
Replace the two negated opt-out builders, Client::without_auth_extension
and Server::without_solicit, with a positive declaration:
moq_net::setup::Extensions { auth, solicit }, #[non_exhaustive] and
all-on by default, set with Client::with_extensions and
Server::with_extensions. Later extensions join the same struct.
Both sides now honor both fields. A client can stop offering Solicit,
and a server can stop offering Auth, which leaves AUTH un-negotiated
whatever the client offered. The private ietf session Config carries
the struct in place of its solicit flag, and run_setup takes it in
place of its two adjacent bools.
moq-tokio mirrors it as an `extensions` field on the dial and listen
configs (connect::Config, listen::Config), read from config files and
omitted when default. The moq-tokio without_* wrappers are gone. js/net
gains an `extensions` option on connect and accept with the same
defaults; a session that did not declare Solicit no longer treats an
unasked PUBLISH_NAMESPACE as a peer fault.
Tests: a_server_may_decline_the_auth_extension (with a control
showing the grant does arrive by default), the existing client-auth and
server-solicit integration tests ported to Extensions, moq-tokio
extensions_default_on_and_parse_off for both configs, and js
extensions.test.ts.
Co-Authored-By: Claude <noreply@anthropic.com>
The request-token credential moves from the Client builder onto the session's auth::Handle, beside session tokens and distinguished by kind: Handle::add presents a connection credential for the whole session, Handle::set_request_token the AUTHORIZATION TOKEN carried on this side's own SUBSCRIBE and PUBLISH_NAMESPACE requests. Client::with_request_token and Client::set_request_token are gone. Set it on Session::auth() before running the driver and the first request carries it; set it again on a live session and every live request re-presents it as a REQUEST_UPDATE, which is client-side renewal. The private ietf session Config no longer carries the token; the session reads it from its handle. moq-tokio seeds it from the dial config: connect::Config::with_request_token applies the token to each session's handle before its driver spawns, so every reconnected session presents it. The seed is skipped by serde and shown by length only in Debug. moq-tokio has no live renewal until Connection::auth() exists; Session::auth() renews on moq-net. Tests: a_request_token_renews_a_subscription_through_the_driver (renewal over the wire through both drivers at draft-18, outliving the first grant's expiry on the same subscription), the integration harness now seeding through Session::auth(), and moq-tokio a_request_token_stays_out_of_debug_and_serde. Co-Authored-By: Claude <noreply@anthropic.com>
Add the four decisions made in review to the request-token quest Plan: the request token rides the auth handle by kind (seeded from connect::Config on moq-tokio until Connection::auth() exists), the positive setup::Extensions declaration, client-side live renewal staying in this quest, and the EXPIRED / MALFORMED split landing with expired-error. Co-Authored-By: Claude <noreply@anthropic.com>
Say why the over-the-driver renewal test runs on real time, and assert that a renewal is only ever acknowledged on draft-17+, where the answer rides the request's own stream. Co-Authored-By: Claude <noreply@anthropic.com>
928d4a5 to
c6c4530
Compare
|
Reworked per your four answers and the review; history rewritten, head c6c4530. (Written by claude-opus-4.8) |
kixelated
left a comment
There was a problem hiding this comment.
Automated review by review (OpenAI)
Reviewed commit: c6c4530701fdabacc1854693480099508e842c70 (reworked delta from 928d4a5; both share base 51c19e2).
The presenter-facing grant checks, local ceilings, Issued::update coverage, renewal range, solicited-namespace filtering and request-message redaction are addressed. Two P2 findings are inline.
Two previous findings are only partly fixed, so I have not duplicated them inline:
- Pending-renewal cancellation:
publisher.rs:831–835buffers a legacy UNSUBSCRIBE and then stops reading; the adapter delivers that terminal message before EOF. With a pending verifier and non-expiring grant, cancellation still hangs. Both renewal loops also miss closure after buffering another update (subscriber.rs:1240–1253). Keep terminal detection active and cover draft-14/15/16 cancellation. - Mixed token/cluster updates: this sender now separates them, but
subscriber.rs:1348–1379still discards a peer's HOP_PATH/ROUTE_COST when renewing. Apply both parts before acknowledging.
Direction: the shared auth handle and positive extension config are improvements. A shared cancellable renewal state would simplify the remaining duplicated lifecycle logic. The moq-tokio live-renewal deferral still needs agreement before Q1/Q3 are considered complete.
Verification: static review of pinned old/new source, surrounding paths, tests and draft rules. No builds/tests run; CI remains queued/running (Android passed).
| // Before draft-17 the update shares the control stream, where an | ||
| // answer keyed by the update's Request ID reaches nothing the peer | ||
| // tracks (and draft-14's SUBSCRIBE_ERROR would read as ending the | ||
| // subscription), so the renewal is decided silently there. | ||
| let answer = | ||
| !matches!(self.version, Version::Draft14 | Version::Draft15 | Version::Draft16); |
There was a problem hiding this comment.
[P2] Keep required renewal responses on draft-15/16
Unlike draft-14, draft-15 §9.11 and draft-16 §9.11 require one REQUEST_OK or REQUEST_ERROR per update. This condition now silently accepts or refuses a valid renewal, leaving an external peer waiting for its result. Restrict silence to draft-14 and fix the adapter's update-ID response routing instead of suppressing the wire response.
| auth: crate::auth::Handle::new(peer_setup.declared.auth), | ||
| // The client's SETUP already settled whether MoQ Auth is negotiated, unless this | ||
| // server does not offer it. | ||
| auth: crate::auth::Handle::new(peer_setup.declared.auth && self.extensions.auth), |
There was a problem hiding this comment.
[P2] Gate incoming AUTH on the server's offer too
With extensions.auth = false, a client can still advertise Auth and send AUTH: ietf/session.rs:442 supplies Some(serve), and :896–899 checks only the peer's offer. auth::Serve::run then grants the empty token or invokes the app verifier despite this handle being unsupported. The Auth draft's Setup Negotiation requires both offers and a PROTOCOL_VIOLATION for AUTH otherwise. Disable inbound serving when the local offer is off and test this asymmetric handshake, as JS already does.
Only the dial paths in `connect_inner` read `Client::request_token`, and every one of them is behind a transport feature, so a no-transport build left the field written but never read and failed clippy's dead-code lint under `-D warnings`. Gate it on `_transport`, like the sibling `timeout` field, so it is present exactly where a reader is. Co-Authored-By: Claude <noreply@anthropic.com>
Draft-15 and draft-16 section 9.11 require one REQUEST_OK or REQUEST_ERROR per REQUEST_UPDATE, so a silent renewal left a conformant peer waiting. Restrict the silence to draft-14 (which defines no per-update response and whose control stream reads a 0x05 as ending the subscription), and on draft-15/16 answer keyed to the update's own Request ID, the id the peer matches against the REQUEST_UPDATE it sent. Draft-17+ is unchanged. Tests drive an accepted and a refused renewal at draft-15 and draft-16 and assert exactly one keyed REQUEST_OK or REQUEST_ERROR, and that an accepted renewal keeps the subscription while a refused one lets the old grant lapse it, never the session. Co-Authored-By: Claude <noreply@anthropic.com>
The dispatch kept the AUTH serve task on the peer's offer alone, so a server that declined MoQ Auth still served a client that advertised it and sent an AUTH stream, granting the empty token or invoking the app verifier on an unsupported handle. The Auth draft's Setup Negotiation requires both offers. Gate serving on the negotiated handle (which carries peer and local offers both) via a small testable predicate, so an AUTH stream a declined endpoint never offered falls through to the UnexpectedStream protocol violation. Co-Authored-By: Claude <noreply@anthropic.com>
Both the subscribe and publish-namespace renewal loops stopped reading the control stream once a message was buffered while a verdict was pending, so a cancellation that arrived afterward was never observed: with a non-expiring grant and an acceptor that never answered the renewal, the request hung. Before draft-17 a cancellation is a message (UNSUBSCRIBE for a subscription, PUBLISH_NAMESPACE_DONE for an announce), not a FIN, so buffering an update first masked it. Keep reading while the verdict is pending: a terminal ends the request now, and a non-terminal update is buffered without halting the read, so a later terminal is never masked. A superseded buffered update is dropped, latest wins. Covers draft-14/15/16 subscription cancellation with a test; the publish-namespace loop keeps its existing withdrawal test. Co-Authored-By: Claude <noreply@anthropic.com>
A PUBLISH_NAMESPACE_UPDATE carrying both a fresh token and HOP_PATH/ ROUTE_COST dropped the cluster parameters: the token branch set the pending renewal and continued before the routing was applied, so the advertisement kept its old route. Apply the routing first, for every update that carries it, then deal with the token; a protocol violation or an unroutable path still tears the announce down regardless of any token. When a token also rides, the routing is already applied and the renewal's answer is the update's single response, so a mixed update still gets exactly one. Covers a mixed update re-routing a token-authorized announce with a test. Co-Authored-By: Claude <noreply@anthropic.com>
|
Pushed five fixes for the second review round and the
On cancellation I kept the subscribe and publish-namespace loops as two shapes rather than a shared cancellable state: their terminal predicates and buffered-message handling differ enough that unifying them would be forced. Happy to unify if you would prefer it. CI: the One open question (moq-tokio live renewal).
(Written by claude-opus-4.8) |
kixelated
left a comment
There was a problem hiding this comment.
Automated review by review (OpenAI)
Reviewed 8c9a8564a92a3e891dd28b04c493d00d4792d75d, the five-commit delta from c6c4530701fdabacc1854693480099508e842c70 on the same base.
The local AUTH-off gate, cancellation detection, and mixed token/cluster application are addressed. Draft-15/16 renewal replies are now emitted, but their round trip remains incomplete.
- P2: preserve buffered wire updates, not just the latest message. subscriber.rs:1267-1271 and publisher.rs:890-893 overwrite an earlier buffered update while verification is pending. With renewal A pending, B changing HOP_PATH followed by C changing only ROUTE_COST loses B's path and response. Multiple token renewals similarly lose an intermediate verdict/response. Updates are sparse deltas; draft-18 §10.9.1 permits cumulative coalescing but still requires each successful update's acknowledgment. Preserve those semantics while watching cancellation, and test two buffered updates followed by a resolved verdict.
Remaining existing behavior, not new findings from this delta:
- The previous adapter-routing issue remains:
subscriber.rs:2411-2430sends a new update ID, butadapter.rs:347-355registers only the initial request. Replies keyed to the update still have no destination. - Refused renewals still keep the old request alive (
publisher.rs:922-933,subscriber.rs:1285-1290). That policy needs reconciliation with draft-16 §9.11.1 and draft-18 §10.9.1, which require failed updates to terminate the subscription/namespace request.
Direction: the fixes improve the auth-handle design; keep explicit per-update state and responses while detecting cancellation in both renewal loops. The declared moq-tokio live-renewal deferral remains a maintainer decision. Verification was static source/diff, test inspection and draft comparison. No builds/tests run; Check, WASM and Platform were still in progress when inspected.
(Written by OpenAI Codex)
Co-Authored-By: Claude <noreply@anthropic.com>
…ding Both renewal loops kept a single buffered-update slot while a token verify was pending, so a second update overwrote the first and dropped its cluster delta and its answer. draft-18 section 10.9.1 lets a receiver coalesce the cumulative deltas of several REQUEST_UPDATE messages but still requires one REQUEST_OK or REQUEST_ERROR per successful update. Keep a bounded FIFO queue of the updates buffered while a verdict is pending and drain it in order, so each is verified and answered and each cluster delta is applied, with cancellation still detected throughout. The bound (draft-19 MAX_REQUEST_UPDATES) stops a peer from growing the queue without limit behind a slow verdict; past it the request ends, not the session. On the announce stream a withdrawal read while a verdict is pending goes to its own slot so it ends the announce ahead of any queued update. Co-Authored-By: Claude <noreply@anthropic.com>
A SUBSCRIBE_UPDATE renewal carries its own Request ID, and on draft-15/16 the receiver keys the REQUEST_OK or REQUEST_ERROR to that update id. The control-stream adapter only registered the subscription under its initial id, so the reply named an id no virtual stream owned: the accept was dropped and the refusal a no-op, and the client's renewal never resolved. Record an outgoing renewal's update id against the subscription it names and route the reply (a REQUEST_OK or REQUEST_ERROR, never another message) to that subscription stream as data, never a close, so the client observes the answer and a refused renewal leaves the subscription open. The recording watches a copy of the outgoing frames and never alters forwarding, so a parse that falls short only leaves a reply unrouted, as before; it is built only for draft-15/16, since draft-14 answers nothing and draft-17+ uses real bidi streams. Forgetting or closing a subscription releases its outstanding renewal mappings. Co-Authored-By: Claude <noreply@anthropic.com>
|
Pushed Buffered REQUEST_UPDATEs ( Pre-draft-17 renewal reply routing ( Refused-renewal policy (your call). You flagged that keeping the old grant on a refused renewal conflicts with the drafts. Checking the texts (verified against draft-17 and draft-18; the rule is unchanged across them and the review cited the draft-16 section 9.11.1 equivalent):
So the drafts do require a failed update to terminate the request; the quest's "leave the old grant until it lapses" is a deliberate deviation. Three ways to reconcile:
The code is on the current keep-old-grant behavior pending your decision; (1) is a small change. Still open from the earlier comment: whether the client wrapper should carry live request-token renewal now, or wait for the (Written by claude-opus-4.8) |
kixelated
left a comment
There was a problem hiding this comment.
Automated review by review (OpenAI)
Reviewed c51dd18555356039dd61d98ecd4690636c189e76, the delta from 8c9a8564 on the same base.
The FIFO fixes the lost buffered updates and sparse cluster deltas within its limit. The draft-15/16 update-ID mapping now routes renewal replies to the real subscriber reader; the previous reply-routing finding is addressed.
- P2: advertise a renewal limit before enforcing it. publisher.rs:901–906 and subscriber.rs:1282–1286 terminate the request once one verification is pending and 16 further updates are queued. However, SETUP:665–681 advertises no MAX_REQUEST_UPDATES. Draft-19 §10.3.1.7 defines omission as zero/unlimited, so a conforming peer can send the next update and unexpectedly lose its subscription or namespace while the verifier is slow; cluster-only reprices behind one token renewal are sufficient. Advertise and enforce consistent finite credit on versions supporting that option, including the in-flight verification in the count, and use a version-appropriate bounded strategy for older drafts. Add a negotiated-limit boundary case; the current flood tests only assert termination.
Direction: preserving per-update state and routing legacy replies are improvements. The existing failed-renewal policy still conflicts with the drafts and remains an explicit maintainer decision, as does moq-tokio live renewal; these are not new findings.
Verification: exact-head source/diff, surrounding SETUP/adapter/subscriber paths, tests and primary draft text. No builds/tests run locally. Android reported success; Check, WASM and Platform were still running when inspected.
(Written by OpenAI Codex)
The per-request renewal buffer is bounded, but SETUP advertised no MAX_REQUEST_UPDATES, and draft-19 section 10.3.1.7 reads an absent option as unlimited. A conforming peer could send one more REQUEST_UPDATE than the hidden ceiling and lose its subscription or namespace while a token verify was still pending. Advertise a finite credit on the drafts that define the option (19+) and enforce exactly that, counting the one being verified toward it. A peer that exceeds the advertised value broke the negotiated rule, so the draft's own remedy applies: close the session with TOO_MANY_REQUEST_UPDATES. A single constant drives both the advertisement and the check so they cannot drift. Drafts below 19 carry no such option, so a peer there agreed to no ceiling: keep a generous local guard that ends only the one request, never the session, rather than strand a conforming peer for a limit it never saw. Co-Authored-By: Claude <noreply@anthropic.com>
|
This round advertises and enforces MAX_REQUEST_UPDATES: a finite credit on the drafts that define it (19+), the draft-prescribed TOO_MANY_REQUEST_UPDATES close on a peer that leaves one more outstanding than that, a version-appropriate local guard below 19, and every REQUEST_UPDATE now answered so its credit is restored. Boundary-tested on both the publisher and subscriber (exactly the limit succeeds, one past closes the session). Rounds 1-4 so far
Open for your call
We will hold further rounds for those two rulings unless a review surfaces a correctness issue inside the current design. (Written by claude-opus-4.8) |
kixelated
left a comment
There was a problem hiding this comment.
Automated review by review (OpenAI)
Reviewed fe264a0339e334d8a52274267e989f8726efb7a9, the one-commit delta from c51dd185 on the same base.
The draft-19+ receiver now advertises the same 16-update credit it enforces, counts the pending verification, and acknowledges tokenless updates. Those parts of the previous finding are addressed. On older drafts the unadvertised guard is raised to 64 queued updates; the same request-termination behavior remains beyond that threshold.
- P2: make the outgoing subscriber honor update credit before enforcing it session-wide. The new publisher.rs:914-919 closes the entire session on the 17th outstanding renewal, but SETUP parsing:632-638 drops the peer's limit, and subscriber.rs:2245-2274 sends each observed token change without waiting for a response. Its response reader only logs acknowledgments. Between two copies of this implementation, hold the first renewal's verifier and make 17 distinct token replacements, allowing the driver to send each: an application using the public auth handle now loses every request on the connection. Draft-19 §10.3.1.7 requires the sender to respect this credit. Either track the peer's per-stream allowance and restore it on responses, or keep one renewal in flight and coalesce unsent replacements. Add a two-driver test with a held verifier and verify that resolving it releases the next renewal without disconnecting.
Direction: the advertised receiver bound is an improvement, but the sending half needs to participate in the contract. The existing refused-renewal policy and moq-tokio live-renewal decisions remain open; I have not repeated them as new findings.
Verification: pinned delta and surrounding SETUP, sender, receiver, response and test code, plus primary draft text. No Rust builds or tests run locally; the returned CI workflows were still running when inspected.
(Written by OpenAI Codex)
Parse the MAX_REQUEST_UPDATES Setup Option a peer advertises (draft-19 section 10.3.1.7) and keep it on the per-session Peer. An absent option and a 0 value both mean no limit, recorded as None. The current sender keeps one renewal in flight per request, so it honors any credit without reading it; recording the value lets a future sender pace to the peer's limit. Co-Authored-By: Claude <noreply@anthropic.com>
The subscriber re-presented a replaced request token as a REQUEST_UPDATE on every change without waiting for a response, so a burst of replacements could leave more renewals outstanding than the serving side's MAX_REQUEST_UPDATES credit (draft-19 section 10.3.1.7). The serving side answers that overflow by closing the session, losing every request on it. Keep at most one renewal unanswered per subscription: a replacement that arrives while one is in flight is coalesced, and the newest is sent once the outstanding one is answered. This stays within any advertised credit without reading it, and matches renewal semantics, where only the newest token matters. Co-Authored-By: Claude <noreply@anthropic.com>
|
This round makes the sender honor the update credit it was already advertising: a subscription keeps at most one request-token renewal (REQUEST_UPDATE) in flight at a time and coalesces replacements that arrive while one is unanswered, so the newest token is sent once the outstanding one is answered. It stays within any MAX_REQUEST_UPDATES a peer advertises (draft-19 section 10.3.1.7) without reading it, and the peer's advertised limit is now recorded from SETUP for a future sender. A two-driver test with a held verifier covers it: a burst of token replacements well past the credit behind one held renewal, the session stays up, and resolving the verifier releases exactly the newest token. Rounds 1-5 so far
Open for your call
We will hold further rounds for those two rulings unless a review surfaces a correctness issue inside the current design. (Written by claude-opus-4.8) |
kixelated
left a comment
There was a problem hiding this comment.
Automated review by review (OpenAI)
Reviewed 79d21c70272f57ee0214dd874cbc3b6ccd763300, the two-commit delta from fe264a03 on the same base.
The outgoing-credit finding is addressed: subscriber.rs:2231-2338 keeps one renewal in flight, coalesces unsent replacements, and releases the newest after an answer. Draft-14 remains unpaced because accepted renewals have no response. SETUP now records peer credit too. The added two-driver regression covers a held verifier, a burst beyond the receiver's limit, session survival and release of only the newest token.
No new actionable bugs found in this delta. Direction: one-in-flight renewal is a suitably simple fix; a generalized credit scheduler is unnecessary for this path. The previously discussed refused-renewal policy and moq-tokio live-renewal scope remain maintainer decisions, not newly fixed findings.
Verification: static review of the pinned delta, sender/response/SETUP paths and regression test. No local builds or tests run. Android CI reported success; Check, WASM and Platform were still running when inspected.
(Written by OpenAI Codex)
Implements
quest/m1/auth/request-token, part A: anAUTHORIZATION TOKEN(0x03) on a request authorizes that request. Base isquest/m1/auth/READMEat51c19e29d.(Written by claude-opus-4.8)
Decisions applied
Reworked per your four answers and both automated reviews; the decisions are recorded in the quest Plan.
auth::Handle::set_request_token. NoClientmethods.Connection::auth()is not in the tree yet, so moq-tokio seeds the token fromconnect::Config::with_request_tokenon every (re)connected session; moq-tokio live renewal arrives withConnection::auth(), and renewal is available on moq-net'sSession::auth()until then. Say if you want the handle built here instead.#[non_exhaustive] setup::Extensions { auth, solicit }, all on by default, viaClient/Server::with_extensions, anextensionsfield on moq-tokio'sconnect::Configandlisten::Config, and anextensionsoption on js/netconnect/accept. It replaces bothwithout_*methods and the privaterun_setupbools.expired-error.Review fixes, one commit each with a regression test:
enforce_grantstand down only when the announce rides a PUBLISH_NAMESPACE (draft-14/15, or a peer that does not require Solicit); an inline NAMESPACE has no token slot, so the connection grant still bounds it.subscribefor SUBSCRIBE,publishfor PUBLISH_NAMESPACE), initially and on renewal, and anIssued::updatethat stops covering the request ends it.Handle::authorize) bounds the token path in both directions, including a later narrowing.Debugoutput.What it does
A request is authorized by the session's grant first; when that does not cover it, by the token on the request; with neither,
UNAUTHORIZED. The token's grant covers only its request, never joins the session union, and ends with the request. A REQUEST_UPDATE with a new token replaces the grant and its expiry; a refused one keeps the old grant until it lapses; a lapse or acceptor revoke ends only that request, never the session. An alias or DELETE form token closes the session PROTOCOL_VIOLATION, as at SETUP. With norequests()consumer a non-empty token is refusedUnsupported, so an app that does not opt in is unchanged, and a token-less peer is byte-identical on the wire.The number of unacknowledged REQUEST_UPDATEs a peer may leave on one request stream is bounded. On the drafts that define it (19+) that bound is advertised as the MAX_REQUEST_UPDATES Setup Option, counting the one being verified; a peer that exceeds the advertised value is closed with TOO_MANY_REQUEST_UPDATES, which is the consequence draft-19 section 10.3.1.7 prescribes. Every REQUEST_UPDATE is answered, token or not, so its credit is restored (section 10.9). Drafts below 19 carry no such option, so a peer there agreed to no ceiling: they keep a generous local guard that ends only the one request, never the session.
The sender honors the same bound without reading it: a subscription keeps at most one renewal in flight and coalesces replacements that arrive behind it, sending the newest once the outstanding one is answered. The announce update path was already one-at-a-time per stream. The peer's advertised limit is recorded from SETUP (an absent option or a 0 both meaning no limit) for a future sender that paces to it exactly.
auth::Requeston the session's handle, tagged with path and kind, regardless of the Auth extension. The token path is entered only when the union does not cover the path, so the token-less fan-out path is untouched.moq_tokio::server::Request::auth()exposes the handle beforeok(), so a QUIC server installs its consumer before the driver's first poll.A lapsed announce grant ends the PUBLISH_NAMESPACE request and blocks new subscribers, but established subscriptions keep flowing; "a lapsed grant stops media" is the subscribe-side property.
Public API and wire impact
Wire: the MAX_REQUEST_UPDATES Setup Option (type 0x08) is advertised on the drafts that define it (19+), and TOO_MANY_REQUEST_UPDATES (code 0x1B) closes the session when a peer exceeds it. The 0x03 request parameter is otherwise defined in every supported draft (draft-17 9.3.2 / draft-18+ 10.2.2; Token structure 8.9 / draft-21 9.1.4; REQUEST_UPDATE draft-17 9.10 / draft-18+ 10.9).
moq-net, additive over the base:
auth::RequestKind;auth::Request::{path, kind, token_kind};auth::Handle::set_request_token;SessionError::TooManyRequestUpdates;setup::Extensions;Client::with_extensions,Server::with_extensions. moq-tokio:connect::Config::{extensions, with_request_token},listen::Config::extensions,server::Request::auth(). js/net:extensionsonConnectProps/AcceptProps. The machinery and the ietf message types are crate-private.Verification
fmt, clippy
-D warnings, nextest (moq-net lib 1457,--test auth82, moq-tokio lib 246), wasm clippy,cargo doc -D warnings, shear, sort,just js check, bun 1103. Renewal is proven over the wire through both drivers at draft-18 (a_request_token_renews_a_subscription_through_the_driver). The negotiated-limit boundary is covered on both the publisher and subscriber: exactly the limit succeeds, one past closes the session with TOO_MANY_REQUEST_UPDATES, and the older-draft guard ends only the request. A two-driver held-verifier test (a_held_renewal_coalesces_a_burst_without_tripping_the_credit) confirms the sender stays within the credit: with the first renewal's verifier held, a burst of token replacements well past the credit keeps the session up and resolving the verifier releases exactly the newest token. Also exercised against a downstream relay built on moq-net over real QUIC with the token seeded on the dial config: token-authorized SUBSCRIBE and PUBLISH_NAMESPACE, lapse ending only the request, alias token closing the session, no-consumer refusal.Follow-ups (out of scope here)
requests()equivalent, as one follow-up; the quest scopes js/net to decode plus default refusal.--request-tokenknob (raw Token bytes vs a JWT to wrap is a semantic choice).moq_auth::Clientlease, which needsrelay-refresh'sClient::attach.Commits carry a
Co-Authored-By: Claudetrailer.