Skip to content

feat(net): a token on a request authorizes that request - #4675

Open
ksletmoe-aws wants to merge 49 commits into
moq-dev:quest/m1/auth/READMEfrom
ksletmoe-aws:quest/m1/auth/request-token
Open

ksletmoe-aws wants to merge 49 commits into
moq-dev:quest/m1/auth/READMEfrom
ksletmoe-aws:quest/m1/auth/request-token

Conversation

@ksletmoe-aws

@ksletmoe-aws ksletmoe-aws commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Implements quest/m1/auth/request-token, part A: an AUTHORIZATION TOKEN (0x03) on a request authorizes that request. Base is quest/m1/auth/README at 51c19e29d.

(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.

  • Q1, credential home. The request token rides the auth handle beside session tokens, distinguished by kind: auth::Handle::set_request_token. No Client methods. Connection::auth() is not in the tree yet, so moq-tokio seeds the token from connect::Config::with_request_token on every (re)connected session; moq-tokio live renewal arrives with Connection::auth(), and renewal is available on moq-net's Session::auth() until then. Say if you want the handle built here instead.
  • Q2, extensions. #[non_exhaustive] setup::Extensions { auth, solicit }, all on by default, via Client/Server::with_extensions, an extensions field on moq-tokio's connect::Config and listen::Config, and an extensions option on js/net connect/accept. It replaces both without_* methods and the private run_setup bools.
  • Q3, client renewal. Kept: setting a new token on the handle re-presents it on every live request as a REQUEST_UPDATE.
  • Q4. No EXPIRED / MALFORMED split; this PR answers UNAUTHORIZED and NOT_SUPPORTED, and the split lands with expired-error.

Review fixes, one commit each with a regression test:

  • The announce filter and enforce_grant stand 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.
  • A token's grant is checked from the presenter's side (subscribe for SUBSCRIBE, publish for PUBLISH_NAMESPACE), initially and on renewal, and an Issued::update that stops covering the request ends it.
  • The local limit (Handle::authorize) bounds the token path in both directions, including a later narrowing.
  • A renewal keeps the subscription's range (token-only on draft-15+; draft-14 restates the start), and a stream closed during a pending renewal ends the request.
  • A reprice carries no token, so HOP_PATH / ROUTE_COST are not dropped; renewals before draft-17 are decided without an answer; token bytes never reach Debug output.
  • MAX_REQUEST_UPDATES is advertised and enforced (see below), replacing the earlier hidden buffer cap.

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 no requests() consumer a non-empty token is refused Unsupported, 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.

  • Decode reuses the SETUP option's Token structure (section 8.9 rules) in both decoder families. fix(ietf): decode every legal request and refuse per request #4610 already ignores unknown keys, so the decode-only accepts on FETCH / PUBLISH / TRACK_STATUS / SUBSCRIBE_NAMESPACE are explicitness; happy to drop them.
  • An uncovered request's token becomes an auth::Request on 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 before ok(), so a QUIC server installs its consumer before the driver's first poll.
  • js/net decodes and drops 0x03 on every request message.

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: extensions on ConnectProps / AcceptProps. The machinery and the ietf message types are crate-private.

Verification

fmt, clippy -D warnings, nextest (moq-net lib 1457, --test auth 82, 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)

  • JS: an outgoing-token setter and an accept-side requests() equivalent, as one follow-up; the quest scopes js/net to decode plus default refusal.
  • moq-cli --request-token knob (raw Token bytes vs a JWT to wrap is a semantic choice).
  • Part B: the relay's per-request moq_auth::Client lease, which needs relay-refresh's Client::attach.
  • A REQUEST_UPDATE token on a request the session grant already covers is ignored; attaching a request grant there is possible but we lean no.

Commits carry a Co-Authored-By: Claude trailer.

ksletmoe-aws and others added 9 commits October 1, 2026 20:27
…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>
@kixelated

Copy link
Copy Markdown
Collaborator

Solid request-token vertical: covers vs allows is wired correctly (None union does not admit a token-bearing request; token-less stays permissive), alias DELETE/USE_ALIAS closes the session as ProtocolViolation, and renewal races the verify against RequestGrant::poll_ended so a slow acceptor cannot outlive the old deadline. Reviewed head 928d4a52f30b063e9c73a9158f77bd777d58c936.

Blocking

  1. Announce self-censor / enforce_grant stand down even when Solicit means the token cannot ride (publisher.rs:1676, session.rs:1092; subscriber subscribe gate at subscriber.rs:1854 is fine because SUBSCRIBE always carries the token).
    • When request_token is set, permitted becomes always-true and dialing-side enforce_grant returns immediately. That is correct for an unsolicited PUBLISH_NAMESPACE (token on the wire; server covers + acceptor are the authority).
    • Under default Solicit, the client answers SUBSCRIBE_NAMESPACE with inline NAMESPACE, which has no token slot (integration test at tests/auth.rs even asserts the acceptor is never consulted). The stand-down still applies, so a client that set a request token for SUBSCRIBE (or for a without_solicit ingest path) can advertise paths outside its connection grant with no request-token verify on the server.
    • Failure scenario: AUTH connection grant is room/alice/*, client also with_request_token(...), peer declared Solicit, origin has room/bob/cam → NAMESPACE advertises room/bob/cam with no token; server attach only applies within_limit / origin scope, not a per-request token check.
    • Suggested fix: stand down announce self-censor / enforce_grant only when the announce will actually carry the token (peer solicit is false/None, or this session used without_solicit); keep filtering when Solicit forces the inline NAMESPACE path. Alternatively, enforce the peer's publish grant on NAMESPACE receive when no request token was verified.

Non-blocking

  1. Known follow-up, still a real cluster bug: PUBLISH_NAMESPACE_UPDATE with a token on a token-authorized announce renews and continues before cluster apply (subscriber.rs:1311-1342), while the sender always attaches the current request token on every reprice (publisher.rs:1897). So for token-authorized cluster announces, reprices that also refresh the token silently drop HOP_PATH / ROUTE_COST even though the sender got REQUEST_OK. Apply cluster params after (or alongside) renewal, or omit an unchanged token from reprice-only updates.

  2. Draft-14/15/16 renewal answers are misrouted by the control-stream adapter. Publisher writes RequestOk / SubscribeError/RequestError with the update's Request ID on the virtual subscribe stream (publisher.rs:840, :846). The peer adapter classifies 0x05 as CloseStream(id) and draft-15/16 RequestOk as Response(id) (adapter.rs:920-925, :949-957) — but the update id was never registered (SUBSCRIBE_UPDATE is FollowUp to the subscription). Answers never reach read_publish_done. Draft-17+ real streams are fine; draft-14 accept is intentionally silent. Media still works (client does not require the answer), but moq-net↔moq-net on 15/16 never observes renewal OK/ERROR. Prefer FollowUp-to-subscription routing for those answers, or document silence on 14–16.

  3. Token bytes can land in logs. PublishNamespace / PublishNamespaceUpdate / related msgs #[derive(Debug)] include authorization_token: Option<Bytes>, and several paths log message = ?msg (e.g. publisher.rs:2089, subscriber.rs:2253, and inbound received publish_namespace). Prefer logging only kind/length/path, not the credential bytes.

  4. CI: Android and Release JS Packages have passed; Check / Test / WASM / macOS / Windows were still pending at review time — re-check before merge.

Verdict

ITERATE

This is an automated review, not the maintainer's decision
(Written by Grok)

@kixelated kixelated left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread rs/moq-net/src/ietf/publisher.rs Outdated
Comment on lines +575 to +578
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()) => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Comment on lines +546 to +550
if let Some(token) = &msg.authorization_token
&& !self
.auth
.covers(crate::auth::Direction::Publish, msg.track_namespace.as_str())
{

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Comment thread rs/moq-net/src/auth.rs
Comment on lines +1072 to +1077
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;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Comment on lines +2343 to +2347
.encode(&ietf::SubscribeUpdate {
request_id,
subscription_request_id,
start_location: ietf::Location { group: 0, object: 0 },
end_group: 0,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Comment thread rs/moq-net/src/ietf/subscriber.rs Outdated
Comment on lines +1220 to +1225
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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

@kixelated

Copy link
Copy Markdown
Collaborator

Maintainer decisions on the four questions:

Q1. Request-token credential: ✅ Option 3, through Connection::auth() alongside session tokens, distinguished by kind. No Client::with_request_token / set_request_token.

Q2. Declining Auth and Solicit: ✅ a positive extensions declaration instead of without_* methods: a #[non_exhaustive] Extensions { auth: bool, solicit: bool } (Default = all on) on moq-net's client/server config, mirrored on moq-tokio's dial and listen Config (and JS). It replaces without_auth_extension / without_solicit and folds in the private run_setup bools; later extensions join the same struct.

Q3. Client-side live renewal: ✅ keep it in this PR, via the auth() handle: changing the request token there re-presents it on live requests (SUBSCRIBE_UPDATE / PUBLISH_NAMESPACE_UPDATE).

Q4. EXPIRED / MALFORMED_AUTH_TOKEN: ✅ later, with the expired-error quest. Keep UNAUTHORIZED / NOT_SUPPORTED here.

Please record these in the quest Plan when you rework. Thanks!

(Written by Claude Opus 5.5)

ksletmoe-aws and others added 18 commits October 1, 2026 23:02
…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>
ksletmoe-aws and others added 8 commits October 1, 2026 23:55
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>
@ksletmoe-aws
ksletmoe-aws force-pushed the quest/m1/auth/request-token branch from 928d4a5 to c6c4530 Compare October 2, 2026 00:55
@ksletmoe-aws

Copy link
Copy Markdown
Contributor Author

Reworked per your four answers and the review; history rewritten, head c6c4530. (Written by claude-opus-4.8)

@kixelated kixelated left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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–835 buffers 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–1379 still 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).

Comment thread rs/moq-net/src/ietf/publisher.rs Outdated
Comment on lines +885 to +890
// 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);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Comment thread rs/moq-net/src/server.rs
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),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

ksletmoe-aws and others added 5 commits October 2, 2026 18:18
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>
@ksletmoe-aws

Copy link
Copy Markdown
Contributor Author

Pushed five fixes for the second review round and the Check failure (c6c453070..8c9a8564a), one commit each with a regression test:

  • Check dead-code: the client request token field is gated to the transport feature that reads it, so it is live in every feature build (moq-tokio).
  • Pre-draft-17 renewal answer: a subscribe renewal is now answered on draft-15 and draft-16, one REQUEST_OK or REQUEST_ERROR keyed to the update, and silent only on draft-14.
  • Declined Auth: a server that declines the Auth extension refuses an inbound AUTH stream as a protocol violation instead of serving a request it never offered.
  • Pending-renewal cancellation: both renewal loops keep reading while a verdict is pending, so a cancellation ends the request on every draft (before draft-17 it is a message, not a stream close) and a buffered later update no longer masks it.
  • Mixed update: a REQUEST_UPDATE carrying both a token and HOP_PATH / ROUTE_COST applies the routing as well as the renewal, with one answer.

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 Check failure was the dead-code above. If the Test / WASM jobs come back red on the Nix cache fetch (HTTP 416), that is the same infra flake as before, not these changes.

One open question (moq-tokio live renewal). Connection::auth() is still not in the tree, so moq-tokio seeds the request token from connect::Config::with_request_token on every (re)connected session, and live renewal is reachable on moq-net's Session::auth() until then. Three ways to close it:

  1. As pushed: keep the config seed; moq-tokio live renewal arrives with Connection::auth() when that lands. (Recommended: no new surface, and the renewal path is already proven on moq-net.)
  2. Build a minimal Connection::auth() now, so moq-tokio gets live renewal in this PR.
  3. A temporary Client::set_request_token shim (you asked for no Client methods, so listed only for completeness).

(Written by claude-opus-4.8)

@kixelated kixelated left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-2430 sends a new update ID, but adapter.rs:347-355 registers 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)

ksletmoe-aws and others added 3 commits October 2, 2026 19:56
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>
@ksletmoe-aws

Copy link
Copy Markdown
Contributor Author

Pushed c51dd1855 addressing the latest review.

Buffered REQUEST_UPDATEs (publisher.rs, subscriber.rs): both renewal loops now keep a bounded FIFO of the updates buffered while a verdict is pending and drain it in order, so each update is verified and answered and each cluster delta applied; the bound (draft-19 MAX_REQUEST_UPDATES) ends the request, not the session, if a peer floods behind a slow verdict.

Pre-draft-17 renewal reply routing (adapter.rs): the control-stream adapter now records a SUBSCRIBE_UPDATE's own Request ID against the subscription it renews and routes the REQUEST_OK / REQUEST_ERROR (keyed to the update id on draft-15/16) back to that subscription stream as data, so a draft-15/16 client observes the answer and a refused renewal keeps the subscription open.

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):

  • draft-17 section 9.10.1 / draft-18 section 10.9.1: "The receiver of a REQUEST_UPDATE MUST respond with exactly one REQUEST_OK or REQUEST_ERROR," and "When a [subscription] update is unsuccessful, the publisher MUST also terminate the subscription with PUBLISH_DONE with error code UPDATE_FAILED"; draft-18 adds that for a SUBSCRIBE_NAMESPACE or PUBLISH_NAMESPACE the responder MUST close the bidi stream.

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:

  1. Follow the drafts (my recommendation): a refused renewal ends that request with the REQUEST_ERROR, the session stays up. Spec-conformant and the more conservative posture (a revoked credential drops its request promptly rather than lingering), and the never-close-the-session property holds either way.
  2. Keep the quest rule as implemented and amend the quest text to record the deliberate deviation.
  3. Make it configurable (listed for completeness; likely over-engineering).

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 Connection::auth() reconnect handle (today it seeds from connect::Config and renews on Session::auth()).

(Written by claude-opus-4.8)

@kixelated kixelated left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@ksletmoe-aws

Copy link
Copy Markdown
Contributor Author

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

  • The grant-bypass blocker: the announce filter and the dialing-side grant stand down only when the announce actually carries a token, so an inline NAMESPACE stays bounded by the connection grant.
  • A session that declines the Auth extension refuses an inbound AUTH stream rather than accepting it.
  • Pre-draft-17 renewals are answered and routed back to the request that sent them.
  • A request is cancellable while its verify is still pending; a renewal keeps the subscription range; a mixed token and cluster update applies both and answers once.
  • Token bytes stay out of Debug output; buffered renewals are each answered, not collapsed.
  • This round: advertised and enforced MAX_REQUEST_UPDATES.

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 kixelated left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

ksletmoe-aws and others added 2 commits October 2, 2026 23:53
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>
@ksletmoe-aws

Copy link
Copy Markdown
Contributor Author

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

  • The grant-bypass blocker: the announce filter and the dialing-side grant stand down only when the announce actually carries a token, so an inline NAMESPACE stays bounded by the connection grant.
  • A session that declines the Auth extension refuses an inbound AUTH stream rather than accepting it.
  • Pre-draft-17 renewals are answered and routed back to the request that sent them.
  • A request is cancellable while its verify is still pending; a renewal keeps the subscription range; a mixed token and cluster update applies both and answers once.
  • Token bytes stay out of Debug output; buffered renewals are each answered, not collapsed.
  • Advertised and enforced MAX_REQUEST_UPDATES: a finite credit on the drafts that define it, the draft-prescribed TOO_MANY_REQUEST_UPDATES close on a peer that leaves one more outstanding than that, a local guard below 19.
  • This round: the sender keeps one renewal in flight per request and coalesces to the newest, and the peer's advertised limit is recorded from SETUP.

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 kixelated left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants