diff --git a/doc/.vitepress/drafts.test.ts b/doc/.vitepress/drafts.test.ts
index f340921693..df79be4f23 100644
--- a/doc/.vitepress/drafts.test.ts
+++ b/doc/.vitepress/drafts.test.ts
@@ -128,20 +128,20 @@ describe("tables render as tables", () => {
const rendered = html(page as DraftPage);
- // The Bidirectional Streams table: one header, six body rows, each
+ // The Bidirectional Streams table: one header, seven body rows, each
// separated by a kramdown rule in the source. Cells carry the source's
// alignment, so match around the style attribute.
const header = /
]*>Stream<\/th>/.exec(rendered);
expect(header).not.toBeNull();
- for (const stream of ["Announce", "Subscribe", "Fetch", "Probe", "Goaway", "Track"]) {
+ for (const stream of ["Announce", "Subscribe", "Fetch", "Probe", "Goaway", "Track", "Auth"]) {
expect(rendered).toMatch(new RegExp(`
]*>${stream}
`));
}
- // One header row plus six body rows, in a single table.
+ // One header row plus seven body rows, in a single table.
const start = rendered.lastIndexOf("
")[0];
- expect(rows.match(/
/g) ?? []).toHaveLength(7);
+ expect(rows.match(/
/g) ?? []).toHaveLength(8);
});
});
diff --git a/doc/bin/relay/auth.md b/doc/bin/relay/auth.md
index 1c60ff2b66..ad23f04621 100644
--- a/doc/bin/relay/auth.md
+++ b/doc/bin/relay/auth.md
@@ -39,10 +39,15 @@ nothing is admitted because the server was down. A grant that names nothing
refuses, and so does one already expired: expiry is exact, with no grace for
clock skew, so keep the auth server's clock in sync.
-**Revalidate.** On the grant's cadence the relay asks again. A grant that no
-longer covers what the session holds, or that changes `root`, `mounts`,
-`peer`, or `upstream`, closes the session; it is not resized in place. A
-changed `tier` keeps the session and moves its stats from then on. A 401 or
+**Revalidate.** On the grant's cadence the relay asks again. A grant with the
+same `root` and `mounts` resizes the live session in place, narrower or wider,
+so a moderation decision lands on the session it targets and can be lifted the
+same way: what falls outside a narrower grant resets with `Unauthorized`, the
+broadcasts the session published outside it abort, and everything else keeps
+flowing. A wider grant brings those paths back, up to what the session was
+admitted with. A one-shot HTTP `/fetch` ends on a narrower grant instead. A
+changed `root`, `mounts`, `peer`, or `upstream` closes the session. A changed
+`tier` keeps the session and moves its stats from then on. A 401 or
403 closes it. Any other failure retries with backoff and the session lives
until `expires`, so an outage always has the bound the server chose. A grant
with `revalidate` must set `expires` for that reason.
@@ -108,6 +113,16 @@ TOKEN` option with Token Type 0. HMAC, RSA, ECDSA, and EdDSA keys all work. A
key can itself be **scoped** at generation (`--root`, `--publish`,
`--subscribe`), after which it can never sign a broader token.
+### On a request
+
+A moq-transport client may also put the `AUTHORIZATION TOKEN` on a SUBSCRIBE or
+PUBLISH\_NAMESPACE, and renew it in place with an update. The session's
+credential is checked first, so a request it covers is served whatever the
+request carries. moq-relay does not verify request tokens yet: one on a request
+the session's credential does not cover is refused `NOT_SUPPORTED`. An
+application built on moq-net can verify them itself with
+`auth::Handle::requests()`.
+
### Claims
| Claim | Meaning |
diff --git a/doc/concept/moq-lite.md b/doc/concept/moq-lite.md
index a99b376019..a754080fb1 100644
--- a/doc/concept/moq-lite.md
+++ b/doc/concept/moq-lite.md
@@ -138,6 +138,24 @@ A subscriber watching under a root sees advertisements named relative to that
root. When several routes advertise one prefix, each reader sees the best route
its scope can use.
+## Authorization
+
+On moq-lite 07 (`moq-lite-07-wip`, opt-in) each side presents a token on its
+own Auth stream and learns what it may publish and subscribe to: a union of
+[path patterns](#path-patterns), delivered exactly as issued rather than widened
+to a prefix. Right after setup both sides present the credential the connection
+already carried (the URL token, a client certificate, or nothing), so a
+publisher learns before anyone subscribes whether its broadcasts can reach the
+peer. More tokens can be added without reconnecting; the session's scope is the
+union of every open token's grant.
+
+A subscription or fetch that loses access resets with the `UNAUTHORIZED` stream
+code and the session stays up. A client that publishes outside its grant closes
+the session with `UNAUTHORIZED`, naming the path. moq-transport carries the same
+exchange on draft-17+ through the [MoQ Auth extension](/draft/moq-auth), limited
+to namespace prefixes. Older versions have no grant; the URL token keeps working
+everywhere.
+
## Subscriptions
A subscriber names a broadcast and track. Delivery starts at the oldest group
diff --git a/doc/concept/standard.md b/doc/concept/standard.md
index 1ecede7dda..6b4f73d9a6 100644
--- a/doc/concept/standard.md
+++ b/doc/concept/standard.md
@@ -29,8 +29,8 @@ subset drops. What a peer actually observes against this implementation:
- **History is one group.** A `FETCH` returns one group from the cache, or on drafts 14 through 19 the saved prefix of the group a new subscription just joined. A range of groups is refused, as is a draft 20+ `LOCATION_FILTER` that ends at Largest Object rather than an absolute End Location. On draft 20 and later, `FETCH_OK`'s End Location is the requested end, capped at Largest Object: a range holding no objects gets an empty fetch stream, and only a start past Largest Object is refused, with `INVALID_RANGE`. A relay's copy with no upstream subscription can't know Largest Object short of the track's end, so it echoes the requested end instead. JavaScript publishing refuses every `FETCH`. Datagrams are never fetchable. A Rust reader that only fetches learns the track from `TRACK_STATUS` instead of subscribing, so a finished track stays fetchable; draft 17 still subscribes, since its `TRACK_STATUS` answer cannot say whether the track is timed.
- **Timing.** A track whose `SUBSCRIBE_OK` declares no `TIMESCALE` is untimed, as is every track on drafts 14 through 16. A Rust reader that never subscribed takes the units from `TRACK_STATUS` on draft 18 and later.
- **Strict SETUP and parameters.** Repeated unknown `SETUP` options, GREASE included, are accepted if well-formed; a repeated known option is rejected. On draft 17 and later, a `GROUP_ORDER` other than Ascending (1) or Descending (2) closes the session. Message parameters follow the negotiated draft's lists: drafts 14 and 15 ignore an unknown or misplaced parameter, draft 16 ignores one defined only for another message but closes the session on an unknown one, and draft 17 and later close it on both.
-- **One credential per session**, carried in `SETUP` and forwarded to the [auth server](/bin/relay/auth#the-contract) unverified. A token attached to an individual request is ignored. An alias reference or a second token closes the session.
-- **Refused, not fatal.** A legal request this stack does not serve is rejected on its own and the session stays up: `FORWARD=0`, range filters, `TRACK_STATUS` to a JavaScript publisher, `SUBSCRIBE_TRACKS`, and the fetch forms above. On draft 19 and later, and in Rust on drafts 14 through 16, a subscription update may change only priority; any other update ends that subscription.
+- **One credential per session**, carried in `SETUP` and forwarded to the [auth server](/bin/relay/auth#the-contract) unverified. A second token closes the session. We advertise no token cache, so an alias reference closes it with `UNKNOWN_AUTH_TOKEN_ALIAS`, and a registration on a request with `AUTH_TOKEN_CACHE_OVERFLOW`. A token on a `SUBSCRIBE` or `PUBLISH_NAMESPACE` authorizes that one request when the session's grant does not cover it and the application verifies request tokens; an update renews it, and a refused renewal ends only that request. A token on any other request is decoded and ignored.
+- **Refused, not fatal.** A legal request this stack does not serve is rejected on its own and the session stays up: `FORWARD=0`, range filters, `TRACK_STATUS` to a JavaScript publisher, `SUBSCRIBE_TRACKS`, and the fetch forms above. On draft 19 and later, and in Rust on drafts 14 through 16, a subscription update may change only priority or renew a request token; any other update ends that subscription.
- **Datagrams** are a single normal object at object 0, forwarded without renumbering. Anything else is dropped, and a malformed one closes the session. Rust and JavaScript both carry them on every draft.
- **Priority.** Higher is served first. The IETF default of 128 is this stack's 127, and a track that never sets one is 127.
- **Size.** An object extension block larger than 64 KiB ends that subgroup stream. The session stays up. This cap is ours, not the draft's.
@@ -39,16 +39,19 @@ Several project drafts extend the IETF wire without breaking it, since `SETUP`
ignores unknown parameters: [cluster](/draft/moq-cluster) routing hop lists,
[solicit](/draft/moq-solicit) to make announcements opt-in,
[hidden](/draft/moq-hidden) to keep `.`-named namespaces out of discovery,
+[auth](/draft/moq-auth) to tell each peer what it may publish and subscribe to,
[active-count](/draft/moq-active-count) to count the `NAMESPACE` messages
before a `SUBSCRIBE_NAMESPACE` is caught up, and
[probe](/draft/moq-probe) for bandwidth estimation.
+Both libraries offer solicit and auth unless a side's `extensions` setting
+declines one, which connects it as a peer that does not speak that extension.
[moq-e2ee](/draft/moq-e2ee) is not a transport extension. It encrypts
application payloads, so relays still forward named tracks they cannot read.
See [Encryption](/concept/hang#encryption).
### Deliberate deviations
-These three answers differ from the draft on purpose. They are the
+These four answers differ from the draft on purpose. They are the
product's model, not bugs, and the relay does not change them.
- **One publisher per path.** A broadcast path names one piece of content, so a
@@ -72,6 +75,11 @@ product's model, not bugs, and the relay does not change them.
requires an established upstream subscription before `SUBSCRIBE_OK`. From
moq-lite 05 the track stream answers first, and a missing track is refused
before `SUBSCRIBE_OK`.
+- **An unknown token alias closes the session.** No alias is ever registered,
+ so a `DELETE` or `USE_ALIAS` token names one that does not exist.
+ [Draft 21 §8.9](https://www.ietf.org/archive/id/draft-ietf-moq-transport-21.html#section-8.9)
+ rejects the message, but `UNKNOWN_AUTH_TOKEN_ALIAS` exists only as a session
+ termination code, so we close the session with it.
## MSF
diff --git a/doc/lib/js/net.md b/doc/lib/js/net.md
index 2294fb07be..38ef4cf374 100644
--- a/doc/lib/js/net.md
+++ b/doc/lib/js/net.md
@@ -42,6 +42,7 @@ broadcast.announce();
- **Timing is per track, with no default.** A track that declares a `timescale` carries a `timestamp` on every frame; one without is [untimed](/concept/moq-lite#subscriptions) and its frames carry none. Unlike Rust, omitting it means untimed. Nothing stamps a frame for you: the publisher passes the timestamp, and the `Timed` type (`{ value, at }`) carries one into the `@moq/json` and `@moq/flate` producers.
- **Subscriber staleness is `maxDelay`.** It is media time. Passing the old `maxAge` key throws a `TypeError` naming `maxDelay`. Publisher retention is the separate `Track.Info.maxAge`.
- **Hidden paths** stay out of discovery unless the announce request opts in. See [hidden broadcasts](/concept/moq-lite#hidden-broadcasts).
+- **Authorization is in band.** On moq-lite 07 (offered only when `moq-lite-07-wip` is listed in `webtransport.protocols`; the WebSocket fallback cannot offer it) and on moq-transport draft-17+ with the [MoQ Auth extension](/draft/moq-auth), `auth.grant` watches what the relay lets this side publish and subscribe to, and `auth.add(token)` presents another token without reconnecting. Publishing outside the grant closes the connection with `SessionCode.Unauthorized`, naming the path. On moq-lite, a subscription the grant stops covering resets with `StreamCode.Unauthorized` and the session stays up.
- **A graceful close waits.** `await connection.close()` withdraws announcements and gives finished tracks up to one second. `abort()` ends immediately.
Examples:
diff --git a/doc/lib/rs/moq-net.md b/doc/lib/rs/moq-net.md
index 7b36df4d72..514bf12e2b 100644
--- a/doc/lib/rs/moq-net.md
+++ b/doc/lib/rs/moq-net.md
@@ -21,6 +21,7 @@ What you use it for, beyond what the concept page already describes:
- **Publish only while someone is watching.** `demand()` on a track, group, or broadcast says whether a subscriber is attached, which is how capture and transcode skip work nobody asked for. Holding a broadcast consumer is not demand. Holding a track consumer is, so drop one you are not reading. A shared fetch stays up until its last reader leaves.
- **You drive the session, or `moq-tokio` does.** `connect` and `accept` return a session plus a driver that never reads the clock itself. `moq_net::time::run` polls it on tokio or in the browser. `moq-tokio` and `moq-wasm` hide that. A custom transport implements `moq_net::transport::poll`.
- **A session caps what its peer can hold.** `session::Limits` (via `Client::with_limits` and `Server::with_limits`) bounds announces and subscriptions per session; the defaults suit a relay mesh, so lower them for untrusted peers. Past either, the session closes with `TOO_MANY_REQUESTS`. On moq-transport drafts 14 to 16 they also size the request-ID window, which every request counts against, so very low limits can starve it. Peer-declared lengths are capped before they are buffered.
+- **Authorization is in band.** On moq-lite 07 (`moq-lite-07-wip`, opt-in) and on moq-transport draft-17+ with the [MoQ Auth extension](/draft/moq-auth), `session.auth()` presents tokens without reconnecting and watches the grant the peer sent back. A server verifies tokens itself by taking `handshake.auth().requests()` before `ok()`. `authorize(&grant)` narrows or widens a live session on any version: what falls outside resets with `Unauthorized` and the session stays up. A client that publishes outside its grant closes the session, naming the path.
- **A graceful close waits.** `session.close().await` withdraws announcements and gives finished tracks up to one second to deliver. `abort` ends immediately. IETF drafts 14 through 16 send withdrawals without waiting.
```bash
diff --git a/drafts/draft-lcurley-moq-auth.md b/drafts/draft-lcurley-moq-auth.md
new file mode 100644
index 0000000000..af2674cfdb
--- /dev/null
+++ b/drafts/draft-lcurley-moq-auth.md
@@ -0,0 +1,229 @@
+---
+title: "MoQ Auth Extension"
+abbrev: "moq-auth"
+category: info
+
+docname: draft-lcurley-moq-auth-latest
+submissiontype: IETF # also: "independent", "editorial", "IAB", or "IRTF"
+number:
+date:
+v: 3
+area: wit
+workgroup: moq
+
+author:
+ -
+ fullname: Luke Curley
+ email: kixelated@gmail.com
+
+normative:
+ moqt: I-D.ietf-moq-transport
+
+informative:
+ moq-lite: I-D.lcurley-moq-lite
+
+--- abstract
+
+This document defines an extension for MoQ Transport {{moqt}} that tells an endpoint what its peer will let it publish and subscribe to.
+Either endpoint presents a token on a request stream of its own, and the peer answers with the namespace prefixes the token grants and when that grant lapses.
+Tokens can be added and withdrawn for the life of the session, so a session can outlive the credential it started with.
+An endpoint that knows its grant can fail loudly on a publication that will never be accepted, instead of waiting for a subscription that never comes.
+
+--- note_Note_to_Readers
+
+This document was generated by an AI model from the implementation at [github.com/moq-dev/moq](https://github.com/moq-dev/moq) and is maintained alongside it.
+Submit an [issue](https://github.com/moq-dev/moq/issues) or [PR](https://github.com/moq-dev/moq/pulls) if this spec sucks and you want to fix anything.
+
+--- middle
+
+# Conventions and Definitions
+{::boilerplate bcp14-tagged}
+
+The **presenter** of a token is the endpoint that sends it in AUTH, and the **acceptor** is the endpoint that answers.
+A **grant** is what the acceptor allows for one token: the namespaces the presenter may publish to it, the namespaces the presenter may subscribe to from it, and an optional expiry.
+
+
+# Introduction
+{{moqt}} authorizes each request on its own.
+A publisher learns that a namespace is refused only by sending PUBLISH_NAMESPACE and reading the REQUEST_ERROR, and learns nothing at all from a relay that forwards only what it is asked for.
+Nothing on the wire says which role an endpoint will ever be allowed to play ([moq-transport issue 1854](https://github.com/moq-wg/moq-transport/issues/1854)), so a client authorized to publish only `alice` that publishes `bob` waits forever, and neither side logs anything.
+
+This extension answers that question once per credential.
+Right after setup each endpoint presents the credential its connection already carried, and learns the grant it earned.
+Further tokens are presented on their own request streams, and the endpoint's scope is the union of every grant it holds.
+
+The exchange is the Auth Stream of {{moq-lite}}, carried as {{moqt}} request streams and negotiated with a Setup Option.
+
+
+# Setup Negotiation
+
+An endpoint that implements this extension sends the following Setup Option ({{moqt}} Section 9.4.1):
+
+~~~
+AUTH Setup Option {
+ Option Key (vi64) = 0x40B60
+ Option Value (vi64) = 1
+}
+~~~
+
+The extension is negotiated when both endpoints sent the option with a value of 1.
+An endpoint MUST NOT send AUTH on a session that did not negotiate it, and an endpoint that receives AUTH anyway MUST close the session with PROTOCOL_VIOLATION, as it would for any unknown message type.
+
+This extension is defined for the versions of {{moqt}} with the unified SETUP message, draft-17 and later.
+
+
+# Auth Requests {#requests}
+
+A presenter opens a bidirectional request stream for each token and sends a single AUTH message on it.
+The stream lives as long as the token, the way a subscription's request stream outlives its SUBSCRIBE_OK.
+
+Each endpoint SHOULD open one Auth request with an empty token as soon as the extension is negotiated.
+An empty token presents the credential the connection already carried: the request URI, a client certificate, an AUTHORIZATION TOKEN Setup Option ({{moqt}} Section 9.4.1), or nothing.
+Its grant tells the presenter which role the acceptor will let it play.
+
+The acceptor answers with AUTH_OK carrying the grant, or AUTH_ERROR refusing the token.
+It MAY send further AUTH_OK messages to replace the grant, such as with a lowered expiry, and the latest AUTH_OK is the token's grant.
+It MAY send AUTH_ERROR after an AUTH_OK to revoke the grant.
+After an AUTH_ERROR the acceptor closes its side of the stream.
+
+The presenter withdraws a token by closing or resetting its side of the stream, and the acceptor then closes its own.
+An acceptor that closes its side without an AUTH_ERROR ends the grant without a reason.
+
+## Scope {#scope}
+
+An endpoint's scope is the union of the grants of its open Auth requests.
+A request that ends, by withdrawal, revocation, or a stream error, removes its grant from the union.
+An empty union grants nothing, and the session stays open for another token.
+
+A grant tells the presenter what the acceptor will allow; it does not replace the acceptor's own enforcement, which MUST still refuse what the grant does not cover.
+
+When the union shrinks, the presenter SHOULD withdraw its advertisements and cancel its subscriptions that the union no longer covers, keeping the session and everything still covered.
+
+A presenter that would advertise a namespace outside the union, once the tokens it presented at setup are answered, SHOULD close the session with UNAUTHORIZED instead of sending PUBLISH_NAMESPACE, since a subscription for it will never come.
+A publication that loses coverage because the union shrank is withdrawn, which is no reason to close the session.
+
+## Prefixes {#prefixes}
+
+A grant names Track Namespace prefixes, encoded as the Track Namespace Prefix of SUBSCRIBE_NAMESPACE ({{moqt}}) and matched the same way: a prefix covers every namespace whose leading fields equal it.
+A prefix with no fields covers every namespace, and a count of zero grants none.
+
+An acceptor whose grant is not a union of prefixes, such as one exact namespace without its descendants, MUST NOT widen it to a prefix.
+It sends AUTH_ERROR with NOT_SUPPORTED instead: as the first reply this refuses the token, and after an AUTH_OK it revokes the earlier grant.
+The same holds for a grant too large to encode as one AUTH_OK: an acceptor MUST NOT trim it, and nothing of the AUTH_OK is written.
+Other tokens on the session are unaffected.
+
+
+# Messages
+
+Each message is a {{moqt}} Control Message, framed by its Message Type and a 16-bit Message Length.
+
+## AUTH {#auth}
+
+AUTH is the first message on an Auth request stream, sent by the presenter.
+
+~~~
+AUTH Message {
+ Type (vi64) = 0x40B61,
+ Length (16),
+ Request ID (vi64),
+ Token Length (vi64),
+ Token (..),
+}
+~~~
+
+**Request ID**:
+The request's identifier, allocated like that of any other request ({{moqt}}).
+
+**Token**:
+The credential to verify, opaque to this extension.
+An empty token presents the credential the connection already carried ({{requests}}).
+
+## AUTH_OK {#auth-ok}
+
+AUTH_OK grants the token, replacing any earlier grant on the same stream.
+
+~~~
+AUTH_OK Message {
+ Type (vi64) = 0x40B62,
+ Length (16),
+ Publish Count (vi64),
+ Publish Prefix (Track Namespace) ...,
+ Subscribe Count (vi64),
+ Subscribe Prefix (Track Namespace) ...,
+ Expires (vi64),
+}
+~~~
+
+**Publish Prefix**:
+A namespace prefix the presenter may advertise and serve ({{prefixes}}).
+
+**Subscribe Prefix**:
+A namespace prefix the presenter may subscribe to and discover.
+
+**Expires**:
+The number of milliseconds until the grant lapses, or 0 for never.
+The acceptor revokes a lapsed grant with AUTH_ERROR, and the presenter uses Expires to present a replacement token in time.
+
+## AUTH_ERROR {#auth-error}
+
+AUTH_ERROR refuses the token, or revokes it after an AUTH_OK.
+
+~~~
+AUTH_ERROR Message {
+ Type (vi64) = 0x40B63,
+ Length (16),
+ Error Code (vi64),
+ Reason Phrase Length (vi64),
+ Reason Phrase (..),
+}
+~~~
+
+**Error Code**:
+A code from the REQUEST_ERROR registry ({{moqt}}): UNAUTHORIZED, MALFORMED_AUTH_TOKEN, EXPIRED_AUTH_TOKEN, or NOT_SUPPORTED ({{prefixes}}).
+
+**Reason Phrase**:
+A human-readable reason, at most 8,192 bytes.
+
+
+# Security Considerations
+
+A token presented in AUTH is as sensitive as one in the request URI or an AUTHORIZATION TOKEN, and relies on the same transport confidentiality.
+
+A grant is advice to the presenter, never authority for the acceptor: the acceptor MUST enforce its own authorization on every request whatever it granted.
+A presenter that trusts a grant too far only fails on the acceptor's REQUEST_ERROR, as it would without this extension.
+
+Auth requests count against the peer's request and stream limits like any other request.
+
+
+# IANA Considerations
+
+This document requests the following registrations.
+High, distinctive values are requested to avoid the low ranges reserved by {{moqt}} and to minimize collisions with provisional registrations by other extensions.
+
+## MOQT Setup Options
+
+This document requests one registration in the "MOQT Setup Options" registry ({{moqt}}), whose policy is Specification Required.
+
+| Value | Name | Reference |
+|:--------|:-----|:--------------|
+| 0x40B60 | AUTH | This Document |
+
+AUTH is even, so its value is a bare varint.
+
+## MOQT Message Types
+
+This document requests three registrations in the "MOQT Message Types" registry ({{moqt}}).
+
+| Value | Name | Reference |
+|:--------|:-----------|:--------------|
+| 0x40B61 | AUTH | This Document |
+| 0x40B62 | AUTH_OK | This Document |
+| 0x40B63 | AUTH_ERROR | This Document |
+
+
+--- back
+
+# Acknowledgments
+{:numbered="false"}
+
+This document was drafted with the assistance of Claude, an AI assistant by Anthropic.
diff --git a/drafts/draft-lcurley-moq-lite.md b/drafts/draft-lcurley-moq-lite.md
index 0ad17479a7..ff410731e6 100644
--- a/drafts/draft-lcurley-moq-lite.md
+++ b/drafts/draft-lcurley-moq-lite.md
@@ -292,8 +292,14 @@ Sent when terminating the session, via the transport's session close.
| ------- | ------------- | ----------- |
| 0x11 | CONTROL_MESSAGE_TIMEOUT | The peer took too long to respond to a control message. |
| ------- | ------------- | ----------- |
+| 0x13 | AUTH_TOKEN_CACHE_OVERFLOW | A token registration would exceed the advertised token cache size. |
+| ------- | ------------- | ----------- |
| 0x15 | VERSION_NEGOTIATION_FAILED | No version could be negotiated. |
| ------- | ------------- | ----------- |
+| 0x17 | UNKNOWN_AUTH_TOKEN_ALIAS | A token named an alias that was never registered. |
+| ------- | ------------- | ----------- |
+| 0x1B | TOO_MANY_REQUEST_UPDATES | The peer left more request updates unanswered than the endpoint allows. |
+| ------- | ------------- | ----------- |
### Stream Error Codes
Sent when resetting a stream (RESET_STREAM), or when refusing to receive one (STOP_SENDING).
@@ -334,6 +340,8 @@ Sent when resetting a stream (RESET_STREAM), or when refusing to receive one (ST
| ------- | ------------- | ----------- |
| 0x3A | NOT_FETCHABLE | The FETCH named a group delivered only as a datagram, which is never cached (see [Datagrams](#datagrams)). It has no moq-transport value; a bridge refuses the FETCH with DOES_NOT_EXIST. |
| ------- | ------------- | ----------- |
+| 0x3B | UNAUTHORIZED | The endpoint does not authorize this request, or no longer does, such as when the [scope](#auth-stream) does not cover it. The session stays up. |
+| ------- | ------------- | ----------- |
Note that CANCELLED is 0x1, not 0x0: a stream reset with 0x0 is an INTERNAL_ERROR, not a routine cancellation.
An endpoint terminating a stream because the session is ending SHOULD use SESSION_CLOSED rather than the session's own code, since the two spaces are disjoint.
@@ -364,6 +372,8 @@ There's a 1-byte STREAM_TYPE at the beginning of each stream.
| ------- | ------------- | ----------- |
| 0x6 | Track | Subscriber |
| ------- | ------------- | ----------- |
+| 0x7 | Auth | Either |
+| ------- | ------------- | ----------- |
### Announce
A subscriber can open an Announce Stream to discover routes matching a prefix.
@@ -520,6 +530,25 @@ In either case the publisher periodically replies with PROBE messages on the sam
If the publisher advertised no Probe capability (e.g., the congestion controller is not exposed), it MUST reset the stream.
+### Auth {#auth-stream}
+Either endpoint can open an Auth Stream (0x7) to present a token and learn what it grants.
+
+The opener sends a single AUTH message and keeps its side open for as long as the token applies.
+Each endpoint SHOULD open one Auth Stream with an empty token right after the session starts, so both learn what the credential the connection already presented (the request URI, a client certificate, or nothing) allows.
+The acceptor replies with an AUTH_OK carrying the grant, or an AUTH_ERROR refusing the token.
+It MAY send further AUTH_OK messages to replace the grant, such as with a lowered expiry, and MAY send an AUTH_ERROR after an AUTH_OK to revoke it; the latest AUTH_OK is the token's grant.
+After an AUTH_ERROR the acceptor closes its side of the stream.
+The opener withdraws a token by closing or resetting its side of the stream, and the acceptor then closes its own.
+
+A grant names the paths the opener may publish to the acceptor and subscribe to from it, relative to the session.
+An endpoint's scope is the union of the grants of its open Auth Streams, and a stream that ends removes its grant from the union.
+When the union shrinks, an endpoint SHOULD withdraw its announcements and cancel its subscriptions that the union no longer covers, keeping the session and everything still covered.
+An endpoint that cancels a subscription, or refuses or stops serving a request, because the union does not cover it resets the stream with UNAUTHORIZED.
+An endpoint that announces a broadcast outside the union once its own setup tokens are answered SHOULD close the session with UNAUTHORIZED, rather than wait for a subscription that will never come.
+
+An acceptor that does not verify a token in band, or cannot express its grant in AUTH_OK, resets the stream, the same as a peer that does not support the Auth Stream (see [STREAM_TYPE](#stream_type)).
+An opener that sees the stream reset before any reply treats the token as unsupported rather than refused.
+
### Goaway
Either endpoint can open a Goaway Stream (0x5) to initiate a graceful session shutdown.
@@ -1330,6 +1359,84 @@ A peer that reconnects to a provided URI SHOULD keep using that URI for subseque
A relay that receives a GOAWAY SHOULD treat the announcements that arrived on that session as the most expensive routes available, so a subscription it can serve from another session moves at the next Group boundary rather than when the draining session finally closes.
The routes stay usable: a broadcast reachable only over the draining session MUST keep being served until the session ends, which is what makes the sender's deadline a handover window rather than a cutoff.
+## AUTH {#auth-message}
+AUTH is the first message on an Auth Stream, presenting a token.
+
+~~~
+AUTH Message {
+ Message Length (i)
+ Token (b)
+}
+~~~
+
+**Token**:
+The credential to verify, opaque to moq-lite.
+An empty token presents the credential the connection already carried, or nothing.
+
+## AUTH_OK {#auth-ok}
+AUTH_OK grants a token, and replaces any grant sent before it on the same stream.
+
+~~~
+AUTH_OK Message {
+ Type (i) = 0x0
+ Message Length (i)
+ Publish Count (i)
+ Publish Pattern (s) ...
+ Subscribe Count (i)
+ Subscribe Pattern (s) ...
+ Expires (i)
+}
+~~~
+
+**Publish Pattern**:
+A [pattern](#path-pattern) matching broadcast paths the opener may announce and serve.
+A count of zero grants none.
+
+**Subscribe Pattern**:
+A [pattern](#path-pattern) matching broadcast paths the opener may subscribe to.
+
+**Expires**:
+The number of milliseconds until the grant lapses, or 0 for never.
+The acceptor revokes a lapsed grant with AUTH_ERROR; the opener uses Expires to present a replacement token in time.
+
+### Path Pattern {#path-pattern}
+A pattern is a set of broadcast paths, written as `/`-separated segments.
+Each segment is one of:
+
+- a literal, matching that segment exactly;
+- `*`, matching any one segment;
+- a literal with one `*` inside it, such as `cam-*.hang`, matching any segment that starts with the bytes before the `*` and ends with the bytes after it, without overlapping them;
+- `**`, matching zero or more segments.
+
+A pattern matches a path when its segments match the path's segments in order, covering the whole path.
+The empty pattern matches only the empty path, `room/**` matches `room` and every path beneath it, and `**` matches every path.
+
+A pattern has at most 32 segments and at most one `**`.
+It has no leading, trailing, or repeated `/`, no segment with more than one `*`, and no `**` combined with other bytes in a segment.
+It is canonical: a `**` is never immediately preceded by a `*` segment, since `*/**` and `**/*` match the same paths and only `**/*` is valid.
+A pattern that breaks any of these rules is a PROTOCOL_VIOLATION.
+
+A grant covers a request when one of its patterns matches the path.
+
+## AUTH_ERROR {#auth-error}
+AUTH_ERROR refuses a token, or revokes it after an AUTH_OK.
+
+~~~
+AUTH_ERROR Message {
+ Type (i) = 0x1
+ Message Length (i)
+ Error Code (i)
+ Reason Phrase (s)
+}
+~~~
+
+**Error Code**:
+A code from the [session error registry](#session-error-codes), such as UNAUTHORIZED.
+A code that does not fit in 32 bits is a PROTOCOL_VIOLATION that closes the session.
+
+**Reason Phrase**:
+A human-readable reason, at most 8,192 bytes.
+
## GROUP
The GROUP message contains information about a Group, as well as a reference to the subscription being served.
@@ -1410,6 +1517,8 @@ The `Message Length` describes the payload size on the wire.
- Added announce compression: ANNOUNCE_START gains `Path Base` and `Path Keep` to copy the head of a live advertisement's suffix, and ANNOUNCE_START and ANNOUNCE_UPDATE gain `Hop Base` and `Hop Keep` to copy the tail of a live advertisement's Hop ID list.
- Capped the Message Length of every message except FRAME at 65,535 bytes.
- Added the TOO_MANY_REQUESTS (0x7) session code, closing a session whose peer goes past the endpoint's bound on subscriptions or announcements.
+- Added the AUTH_TOKEN_CACHE_OVERFLOW (0x13), UNKNOWN_AUTH_TOKEN_ALIAS (0x17), and TOO_MANY_REQUEST_UPDATES (0x1B) session codes, with moq-transport's meaning.
+- Added the Auth Stream (0x7) with AUTH, AUTH_OK, and AUTH_ERROR: either endpoint presents a token on its own stream and learns the [path patterns](#path-pattern) it may publish and subscribe to. The union of a session's open grants is its scope. A shrink withdraws what it no longer covers, and an announcement outside the scope closes the session with UNAUTHORIZED. A peer without the stream resets it.
## moq-lite-06
@@ -1425,6 +1534,7 @@ The `Message Length` describes the payload size on the wire.
- Split the reserved stream error range: 32 through 47 stays reserved, and 48 through 63 is moq-lite's own, assigned by the tables and mapped rather than forwarded across a bridge. Assigned 0x32 GROUP_TOO_LARGE: a group that grew past the publisher's cache budget is aborted. Every code is terminal.
- Assigned 0x33 NOT_FOUND, 0x34 OLD, and 0x35 EVICTED in the stream error table: a group the publisher cannot serve because it was never here, has been superseded, or was dropped under memory pressure.
- Assigned 0x36 UNROUTABLE, 0x37 WRONG_SIZE, 0x38 FRAME_TOO_LARGE, and 0x39 TIMESTAMP_MISMATCH in the stream error table, moving them out of the reserved 32 through 47 range, which no longer carries provisional placeholders.
+- Assigned 0x3B UNAUTHORIZED in the stream error table: a request reset because the endpoint does not authorize it, or no longer does, distinct from SESSION_CLOSED.
- Assigned 0x31 CONTROL_TIMEOUT in the stream error table: a request stream torn down because the peer never answered, which DELIVERY_TIMEOUT described as late content. It has no moq-transport value and bridges to INTERNAL_ERROR.
- A disallowed stream type, a role mismatch, or a missing extension is a PROTOCOL_VIOLATION; the session table gains no code for them, so nothing is sent from the reserved 32 through 47 range in either registry.
- Added implicit Announce IDs: each ANNOUNCE_START assigns the next per-stream ordinal.
@@ -1577,6 +1687,10 @@ moq-lite inherits the transport security of the underlying connection: QUIC and
## Bandwidth Probing
The `Increase` Probe level (see [Probe Parameter](#probe-parameter)) lets a subscriber ask the publisher to pad the connection up to a target bitrate. A publisher MUST NOT treat the target as authorization to send beyond what congestion control allows: padding is bounded by the congestion window, so probing cannot be used to amplify traffic toward the subscriber or a spoofed address. A publisher that only advertised `Report` MUST NOT pad above its current sending rate. Because all data flows on an established, congestion-controlled session to the connecting peer, moq-lite offers no off-path amplification vector.
+## Authorization
+An AUTH_OK tells the opener what the acceptor will allow; it does not replace the acceptor's own enforcement, which MUST still refuse what the grant does not cover.
+A token presented on an Auth Stream is as sensitive as one in the request URI, and relies on the same transport confidentiality.
+
## Session Redirection
GOAWAY carries an optional New Session URI that asks the peer to reconnect elsewhere. A malicious or compromised peer could use this to redirect a client to an attacker-controlled server. A recipient MUST validate the URI against local policy (scheme, authority, and port) before reconnecting, and MUST NOT reconnect if validation fails (see [GOAWAY](#goaway)). Migrated subscriptions carry no implicit trust from the prior session; the new session is authenticated independently.
diff --git a/js/net/src/auth.test.ts b/js/net/src/auth.test.ts
new file mode 100644
index 0000000000..44dec229d1
--- /dev/null
+++ b/js/net/src/auth.test.ts
@@ -0,0 +1,396 @@
+import { describe, expect, spyOn, test } from "bun:test";
+import type { Getter } from "@moq/signals";
+import { type Grant, type Issued, Unsupported } from "./auth.ts";
+import { accept as acceptSession, connect as connectSession, type Established } from "./connection/index.ts";
+import { SessionCode, SessionError, StreamCode, toStreamCode } from "./error.ts";
+import * as Ietf from "./ietf/index.ts";
+import { LiteAuthWire } from "./lite/auth.ts";
+import * as Lite from "./lite/index.ts";
+import { createMockTransportPair, type MockTransport } from "./mock.ts";
+import { Producer as OriginProducer } from "./origin.ts";
+import * as Path from "./path.ts";
+import { Writer } from "./stream.ts";
+import { withTimeout } from "./util/timeout.ts";
+import { wireOf } from "./wire.ts";
+
+const url = new URL("https://localhost:4443/test");
+
+/** The next route event, skipping the live marker. */
+async function nextRoute(announced: {
+ next(): Promise;
+}): Promise | undefined> {
+ for (;;) {
+ const event = await announced.next();
+ if (event?.kind !== "live") return event as Exclude | undefined;
+ }
+}
+
+function patterns(...prefixes: string[]): Path.Patterns {
+ return new Path.Patterns(prefixes.map((prefix) => Path.Pattern.subtree(prefix)));
+}
+
+function grant(publish: string[], subscribe: string[]): Grant {
+ return { publish: patterns(...publish), subscribe: patterns(...subscribe) };
+}
+
+async function waitFor(getter: Getter, ready: (value: T) => boolean): Promise {
+ let value = getter.peek();
+ while (!ready(value)) value = await getter.changed();
+ return value;
+}
+
+interface Pair {
+ client: Established;
+ server: Established;
+ transport: MockTransport;
+}
+
+async function connect(opts: { publish?: OriginProducer; serverPublish?: OriginProducer; protocol: string }) {
+ const pair = createMockTransportPair(opts.protocol);
+ const [client, server] = await Promise.all([
+ connectSession({ url, transport: pair.client, publish: opts.publish?.consume() }),
+ acceptSession({ transport: pair.server, url, publish: opts.serverPublish?.consume() }),
+ ]);
+ return { client, server, transport: pair.client } satisfies Pair;
+}
+
+// Every case runs on moq-lite-07-wip and on moq-transport with the MoQ Auth extension.
+describe.each([Lite.ALPN_07_WIP, Ietf.ALPN.DRAFT_17, Ietf.ALPN.DRAFT_22])("%s", (protocol) => {
+ test("both sides learn their default grant", async () => {
+ const { client, server } = await connect({ publish: new OriginProducer(), protocol });
+
+ // The server consumes anything and publishes nothing.
+ const clientGrant = await waitFor(client.auth.grant, (g) => g !== undefined);
+ expect(clientGrant?.publish.equals(patterns(""))).toBe(true);
+ expect(clientGrant?.subscribe.size).toBe(0);
+
+ // The client publishes, so the server may subscribe to anything.
+ const serverGrant = await waitFor(server.auth.grant, (g) => g !== undefined);
+ expect(serverGrant?.publish.equals(patterns(""))).toBe(true);
+ expect(serverGrant?.subscribe.equals(patterns(""))).toBe(true);
+
+ client.abort();
+ server.abort();
+ });
+
+ test("a token without an acceptor reports unsupported", async () => {
+ const { client, server } = await connect({ publish: new OriginProducer(), protocol });
+ await waitFor(client.auth.grant, (g) => g !== undefined);
+ await expect(client.auth.add("token")).rejects.toBeInstanceOf(Unsupported);
+ client.abort();
+ server.abort();
+ });
+
+ test("an out-of-scope broadcast closes the session and names the path", async () => {
+ const origin = new OriginProducer();
+ const { client, server, transport } = await connect({ publish: origin, protocol });
+ const requests = server.auth.requests();
+ const issued: Issued[] = [];
+ void (async () => {
+ for (;;) {
+ const request = await requests.next();
+ if (!request) break;
+ issued.push(request.accept(grant(["baz"], [])));
+ }
+ })();
+
+ await waitFor(client.auth.grant, (g) => g !== undefined);
+ origin.createBroadcast(Path.from("baz/ok")).announce();
+ origin.createBroadcast(Path.from("foo/bar")).announce();
+
+ const info = await transport.closed;
+ expect(info.closeCode).toBe(SessionCode.Unauthorized);
+ expect(info.reason).toBe("unauthorized: foo/bar");
+ server.abort();
+ });
+
+ test("a revoked grant withdraws its broadcasts without closing the session", async () => {
+ const origin = new OriginProducer();
+ const { client, server, transport } = await connect({ publish: origin, protocol });
+ const requests = server.auth.requests();
+ const issued: Issued[] = [];
+ void (async () => {
+ for (;;) {
+ const request = await requests.next();
+ if (!request) break;
+ issued.push(request.accept(grant(["a"], [])));
+ }
+ })();
+
+ await waitFor(client.auth.grant, (g) => g !== undefined);
+ origin.createBroadcast(Path.from("a/x")).announce();
+
+ const announced = server.announced();
+ const first = await nextRoute(announced);
+ expect(first?.prefix).toBe(Path.from("a/x"));
+ expect(first?.kind).toBe("start");
+
+ issued[0]?.revoke(SessionCode.Unauthorized, "expired");
+ const second = await nextRoute(announced);
+ expect(second?.prefix).toBe(Path.from("a/x"));
+ expect(second?.kind).toBe("end");
+
+ // The union is empty but still a grant, and a new token restores it.
+ const empty = await waitFor(client.auth.grant, (g) => g !== undefined && g.publish.size === 0);
+ expect(empty?.subscribe.size).toBe(0);
+ const token = await client.auth.add("again");
+ expect(token.grant.peek()?.publish.equals(patterns("a"))).toBe(true);
+ const third = await nextRoute(announced);
+ expect(third?.kind).toBe("start");
+
+ let closed = false;
+ void transport.closed.then(() => {
+ closed = true;
+ });
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ expect(closed).toBe(false);
+
+ announced.close();
+ client.abort();
+ server.abort();
+ });
+
+ test("a refused token surfaces the acceptor's code and reason", async () => {
+ const { client, server } = await connect({ publish: new OriginProducer(), protocol });
+ const requests = server.auth.requests();
+ void (async () => {
+ for (;;) {
+ const request = await requests.next();
+ if (!request) break;
+ if (request.token.byteLength === 0) request.accept(grant([], []));
+ else request.reject(SessionCode.Unauthorized, "bad signature");
+ }
+ })();
+
+ const err = await client.auth.add("forged").catch((e: unknown) => e);
+ expect(err).toBeInstanceOf(SessionError);
+ expect((err as SessionError).code).toBe(SessionCode.Unauthorized);
+ client.abort();
+ server.abort();
+ });
+
+ test("closing the session drops the grant", async () => {
+ const { client, server } = await connect({ publish: new OriginProducer(), protocol });
+ await waitFor(client.auth.grant, (g) => g !== undefined && g.publish.size > 0);
+
+ // Checked before any stream notices the transport closing.
+ client.abort();
+ const closed = client.auth.grant.peek();
+ expect(closed?.publish.size).toBe(0);
+ expect(closed?.subscribe.size).toBe(0);
+ server.abort();
+ });
+
+ test("an acceptor that ends a grant sees its stream close", async () => {
+ const { client, server } = await connect({ publish: new OriginProducer(), protocol });
+ try {
+ const requests = server.auth.requests();
+ const request = await requests.next();
+ if (!request) throw new Error("the setup token never arrived");
+ const issued = request.accept(grant(["a"], []));
+ await waitFor(client.auth.grant, (g) => g !== undefined && g.publish.size > 0);
+
+ issued.close();
+ // A regression leaves `closed` pending, so bound the wait rather than hang the runner.
+ expect(await withTimeout(Promise.resolve(issued.closed), 1000, "the grant stream never closed")).toBeNull();
+ } finally {
+ client.abort();
+ server.abort();
+ }
+ });
+
+ test("a refused setup token grants nothing rather than everything", async () => {
+ const { client, server } = await connect({ publish: new OriginProducer(), protocol });
+ const requests = server.auth.requests();
+ void (async () => {
+ for (;;) {
+ const request = await requests.next();
+ if (!request) break;
+ request.reject(SessionCode.Unauthorized, "bad credential");
+ }
+ })();
+
+ const empty = await waitFor(client.auth.grant, (g) => g !== undefined);
+ expect(empty?.publish.size).toBe(0);
+ expect(empty?.subscribe.size).toBe(0);
+ client.abort();
+ server.abort();
+ });
+});
+
+// moq-transport carries namespace prefixes, so a grant that is not a union of subtrees
+// is refused there rather than widened.
+describe.each([Ietf.ALPN.DRAFT_17, Ietf.ALPN.DRAFT_22])("%s", (protocol) => {
+ test("a grant namespace prefixes cannot carry is unsupported and leaves the rest alone", async () => {
+ const { client, server, transport } = await connect({ publish: new OriginProducer(), protocol });
+ const requests = server.auth.requests();
+ const issued: Issued[] = [];
+ void (async () => {
+ for (;;) {
+ const request = await requests.next();
+ if (!request) break;
+ const token = new TextDecoder().decode(request.token);
+ const unions: Record = {
+ exact: ["room/alice"],
+ mixed: ["room/**", "lobby"],
+ wildcard: ["room/*/cam"],
+ };
+ const union = unions[token];
+ const granted = union
+ ? { publish: new Path.Patterns(union.map((p) => Path.Pattern.parse(p))), subscribe: patterns() }
+ : token === "t1"
+ ? grant(["b"], [])
+ : grant(["a"], []);
+ issued.push(request.accept(granted));
+ }
+ })();
+
+ await waitFor(client.auth.grant, (g) => g?.publish.equals(patterns("a")) === true);
+ for (const token of ["exact", "mixed", "wildcard"]) {
+ await expect(client.auth.add(token)).rejects.toBeInstanceOf(Unsupported);
+ }
+ expect(client.auth.grant.peek()?.publish.equals(patterns("a"))).toBe(true);
+
+ // An update namespace prefixes cannot carry revokes that token's grant, and only that one.
+ const t1 = await client.auth.add("t1");
+ await waitFor(client.auth.grant, (g) => g?.publish.equals(patterns("a", "b")) === true);
+ issued[issued.length - 1]?.update({
+ publish: new Path.Patterns([Path.Pattern.literal("b/exact")]),
+ subscribe: patterns(),
+ });
+ await t1.closed;
+ await waitFor(client.auth.grant, (g) => g?.publish.equals(patterns("a")) === true);
+
+ let closed = false;
+ void transport.closed.then(() => {
+ closed = true;
+ });
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ expect(closed).toBe(false);
+ client.abort();
+ server.abort();
+ });
+});
+
+// moq-lite carries patterns, so every grant arrives exactly as issued.
+test("lite-07 carries literal and wildcard grants exactly", async () => {
+ const { client, server } = await connect({ publish: new OriginProducer(), protocol: Lite.ALPN_07_WIP });
+ const requests = server.auth.requests();
+ const unions: Record = {
+ "": [["a/**"], []],
+ exact: [["room/alice"], []],
+ wildcard: [["room/*/cam"], ["**/demo.hang"]],
+ mixed: [["room/**", "lobby", "cam-*.hang"], []],
+ root: [[""], ["**"]],
+ };
+ const parse = (texts: string[]) => new Path.Patterns(texts.map((text) => Path.Pattern.parse(text)));
+ const issued: Issued[] = [];
+ void (async () => {
+ for (;;) {
+ const request = await requests.next();
+ if (!request) break;
+ const [publish, subscribe] = unions[new TextDecoder().decode(request.token)] ?? [[], []];
+ issued.push(request.accept({ publish: parse(publish), subscribe: parse(subscribe) }));
+ }
+ })();
+
+ await waitFor(client.auth.grant, (g) => g !== undefined);
+ for (const token of ["exact", "wildcard", "mixed", "root"]) {
+ const [publish, subscribe] = unions[token];
+ const added = await client.auth.add(token);
+ const got = added.grant.peek();
+ expect(got?.publish.equals(parse(publish))).toBe(true);
+ expect(got?.subscribe.equals(parse(subscribe))).toBe(true);
+ }
+ client.abort();
+ server.abort();
+});
+
+test.each([Lite.ALPN_05, Lite.ALPN_06, Ietf.ALPN.DRAFT_16])("%s has no grant", async (protocol) => {
+ const { client, server } = await connect({ publish: new OriginProducer(), protocol });
+ expect(client.auth.grant.peek()).toBeUndefined();
+ await expect(client.auth.add("token")).rejects.toBeInstanceOf(Unsupported);
+ client.abort();
+ server.abort();
+});
+
+// lite-07 presents the connection's credential on an Auth Stream right away; lite-06 never
+// opens one.
+test.each([
+ [Lite.ALPN_06, false],
+ [Lite.ALPN_07_WIP, true],
+])("%s opens an Auth Stream: %p", async (protocol, opens) => {
+ const presented = spyOn(LiteAuthWire.prototype, "present");
+ try {
+ const { client, server } = await connect({ publish: new OriginProducer(), protocol });
+ expect(presented).toHaveBeenCalledTimes(opens ? 2 : 0);
+ client.abort();
+ server.abort();
+ } finally {
+ presented.mockRestore();
+ }
+});
+
+// moq-transport has no stream code for it, so only moq-lite resets with UNAUTHORIZED.
+test("a revoked grant resets its subscriptions with UNAUTHORIZED", async () => {
+ const clientOrigin = new OriginProducer();
+ const up = clientOrigin.createBroadcast(Path.from("up/y"));
+ up.announce();
+ const upTrack = up.createTrack("video");
+
+ const serverOrigin = new OriginProducer();
+ const down = serverOrigin.createBroadcast(Path.from("room/x"));
+ down.announce();
+ const downTrack = down.createTrack("video");
+
+ const { client, server } = await connect({
+ publish: clientOrigin,
+ serverPublish: serverOrigin,
+ protocol: Lite.ALPN_07_WIP,
+ });
+ const requests = server.auth.requests();
+ const issued: Issued[] = [];
+ void (async () => {
+ for (;;) {
+ const request = await requests.next();
+ if (!request) break;
+ issued.push(request.accept(grant(["up"], ["room"])));
+ }
+ })();
+ await waitFor(client.auth.grant, (g) => g !== undefined && g.subscribe.size > 0);
+
+ const frame = { payload: new Uint8Array([1]) };
+ downTrack.appendGroup().writeFrame(frame);
+ upTrack.appendGroup().writeFrame(frame);
+
+ // The client subscribes to the server, and the server to the client.
+ const downSub = wireOf(client).consume(Path.from("room/x")).track("video").subscribe().ordered();
+ const upSub = wireOf(server).consume(Path.from("up/y")).track("video").subscribe().ordered();
+ expect(await downSub.nextGroup()).toBeDefined();
+ expect(await upSub.nextGroup()).toBeDefined();
+
+ const resets = spyOn(Writer.prototype, "reset");
+ try {
+ issued[0]?.revoke(SessionCode.Unauthorized, "expired");
+ const drained = async (track: typeof downSub) => {
+ for (;;) if (!(await track.nextGroup())) break;
+ };
+ await Promise.all([drained(downSub).catch(() => void 0), drained(upSub).catch(() => void 0)]);
+
+ const reasons = resets.mock.calls.map(([reason]) => reason);
+ // Both sides of the revocation: the subscription the client cancels, and the one it
+ // stops serving. Nothing reads as the session closing.
+ const messages = reasons.map((reason) => (reason as Error).message);
+ expect(messages).toContain("unauthorized: room/x");
+ expect(messages).toContain("unauthorized: up/y");
+ for (const reason of reasons) {
+ expect([StreamCode.Unauthorized, StreamCode.Cancel]).toContain(toStreamCode(reason));
+ }
+ } finally {
+ resets.mockRestore();
+ downSub.close();
+ upSub.close();
+ client.abort();
+ server.abort();
+ }
+});
diff --git a/js/net/src/auth.ts b/js/net/src/auth.ts
new file mode 100644
index 0000000000..a3f371426c
--- /dev/null
+++ b/js/net/src/auth.ts
@@ -0,0 +1,125 @@
+/**
+ * In-band authorization: present tokens to the peer and learn what they grant.
+ *
+ * Each side of a moq-lite-07 session, and of a moq-transport draft-17+ session when both
+ * sides negotiate MoQ Auth, presents the credential its connection already carried (the
+ * URL, or nothing) right after setup, and learns the {@link Grant} it earned. Older
+ * versions, and peers that do not negotiate it, carry no AUTH exchange: the grant stays
+ * undefined and {@link Auth.add} rejects with {@link Unsupported}. {@link Auth.grant} is
+ * the union of every token this side presented, and {@link Auth.add} presents another
+ * without reconnecting. Mirrors the Rust `moq_net::auth`.
+ *
+ * @module
+ */
+
+import { type Getter, Signal } from "@moq/signals";
+import type { SessionCode } from "./error.ts";
+import type * as Path from "./path.ts";
+
+/**
+ * What a peer lets this side do, in this side's own paths (relative to the session).
+ *
+ * An empty {@link Path.Patterns} grants nothing; `**` grants everything.
+ */
+export interface Grant {
+ /** The broadcasts this side may publish to the peer. */
+ publish: Path.Patterns;
+ /** The broadcasts this side may subscribe to from the peer. */
+ subscribe: Path.Patterns;
+ /** When the grant lapses, in `Date.now()` milliseconds, or undefined for never. */
+ expires?: number;
+}
+
+/** Whether two grants allow the same paths and lapse together. */
+export function grantsEqual(a: Grant | undefined, b: Grant | undefined): boolean {
+ if (a === undefined || b === undefined) return a === b;
+ return a.publish.equals(b.publish) && a.subscribe.equals(b.subscribe) && a.expires === b.expires;
+}
+
+/** The session or peer carries no AUTH exchange, or takes no tokens in band. */
+export class Unsupported extends Error {
+ constructor(message = "auth unsupported") {
+ super(message);
+ this.name = "Unsupported";
+ }
+}
+
+/** A token this side presented. {@link Token.close} withdraws it. */
+export interface Token {
+ /** This token's own grant: undefined until the peer answers, and again once it ends. */
+ readonly grant: Getter;
+ /** Settles when the token ends: the peer's revocation, or its withdrawal. */
+ readonly closed: PromiseLike;
+ /** Withdraw the token, removing its grant from the union. */
+ close(): void;
+}
+
+/** A token the peer presented, waiting for an answer. Answer every one. */
+export interface Request {
+ /** The token the peer presented. Empty means the credential its connection carried. */
+ readonly token: Uint8Array;
+ /**
+ * Grant the token until the returned {@link Issued} is revoked or closed.
+ *
+ * The session only tells the peer; the origins this side serves and accepts are
+ * what enforce the grant, and revoking before `expires` is the acceptor's job.
+ */
+ accept(grant: Grant): Issued;
+ /** Refuse the token with a session code and a reason for the peer. */
+ reject(code: SessionCode, reason: string): void;
+}
+
+/** A grant issued to one of the peer's tokens. */
+export interface Issued {
+ /** Replace the grant, such as with a lowered expiry. */
+ update(grant: Grant): void;
+ /** Revoke the grant with a session code and a reason for the peer. */
+ revoke(code: SessionCode, reason: string): void;
+ /** End the grant without a reason. */
+ close(): void;
+ /** Settles once the peer withdraws the token or its stream ends. */
+ readonly closed: PromiseLike;
+}
+
+/** The tokens the peer presents, for an application that answers them itself. */
+export interface Requests {
+ /** The next token the peer presents, or undefined once the session ends. */
+ next(): Promise;
+ /** Refuse every later token. */
+ close(): void;
+}
+
+/** A session's tokens and grants. See the module docs. */
+export interface Auth {
+ /** The union of every grant this side holds: undefined until the peer first answers. */
+ readonly grant: Getter;
+
+ /**
+ * Present another token, resolving once the peer answers it. Rejects with
+ * {@link Unsupported} when the session or the peer takes no tokens in band, or with the
+ * peer's refusal as an `Error.Session`.
+ */
+ add(token: string | Uint8Array): Promise;
+
+ /**
+ * Answer every token the peer presents, instead of the default: granting what this side
+ * publishes and consumes for the connection's own credential and refusing any other.
+ *
+ * Call it as soon as connect or accept resolves, in the same task: whoever answers is
+ * decided once, right after. Throws once that has happened.
+ */
+ requests(): Requests;
+}
+
+/** The {@link Auth} of a session without AUTH: no grant, and no tokens. */
+export class None implements Auth {
+ readonly grant: Getter = new Signal(undefined);
+
+ add(_token: string | Uint8Array): Promise {
+ return Promise.reject(new Unsupported());
+ }
+
+ requests(): Requests {
+ return { next: async () => undefined, close: () => {} };
+ }
+}
diff --git a/js/net/src/auth_session.ts b/js/net/src/auth_session.ts
new file mode 100644
index 0000000000..ac9c3824c6
--- /dev/null
+++ b/js/net/src/auth_session.ts
@@ -0,0 +1,483 @@
+import { type Dispose, type Getter, Signal } from "@moq/signals";
+import {
+ type Auth as AuthApi,
+ type Grant,
+ grantsEqual,
+ type Issued,
+ type Request,
+ type Requests,
+ type Token,
+ Unsupported,
+} from "./auth.ts";
+import { closeReason, error, SessionCode, StreamCode, StreamError } from "./error.ts";
+import * as Path from "./path.ts";
+import type { Stream } from "./stream.ts";
+
+/** A grant as the wire carries it: the expiry is milliseconds from now. @internal */
+export interface WireGrant {
+ publish: Path.Patterns;
+ subscribe: Path.Patterns;
+ expires?: number;
+}
+
+/** One reply read off a presented token's stream. @internal */
+export type WireReply = { grant: WireGrant } | { refused: Error };
+
+/**
+ * How one wire carries AUTH, AUTH_OK, and AUTH_ERROR, so moq-lite and moq-transport share
+ * the token lifecycle.
+ *
+ * @internal
+ */
+export interface AuthWire {
+ /** Open a stream and present the token on it. */
+ present(token: Uint8Array): Promise;
+ /** The next reply on a presented token's stream, or undefined once the acceptor finished it. */
+ read(stream: Stream): Promise;
+ /** The token the peer presents on a stream it opened. */
+ accept(stream: Stream): Promise;
+ /** Tell the presenter a grant. Throws {@link Unsupported} when this wire cannot represent it. */
+ grant(stream: Stream, grant: WireGrant): Promise;
+ /** Refuse or revoke the presenter's token. */
+ refuse(stream: Stream, code: SessionCode, reason: string): Promise;
+ /** End the stream telling the presenter its token or grant cannot be answered here. */
+ unsupported(stream: Stream, reason: string): Promise;
+}
+
+function union(grants: Iterable): Grant {
+ const publish = new Path.Patterns();
+ const subscribe = new Path.Patterns();
+ let expires: number | undefined;
+ for (const grant of grants) {
+ for (const pattern of grant.publish) publish.insert(pattern);
+ for (const pattern of grant.subscribe) subscribe.insert(pattern);
+ // The earliest expiry is when the union next shrinks.
+ if (grant.expires !== undefined) expires = Math.min(expires ?? grant.expires, grant.expires);
+ }
+ return { publish, subscribe, expires };
+}
+
+function cancel(): StreamError {
+ return new StreamError(StreamCode.Cancel, { message: "cancel" });
+}
+
+/** One token this side presented. */
+class Presented implements Token {
+ readonly grant = new Signal(undefined);
+ readonly closed: Promise;
+ readonly answered: Promise;
+ readonly setup: boolean;
+ readonly token: Uint8Array;
+
+ stream?: Stream;
+ withdrawn = false;
+ isAnswered = false;
+ ended = false;
+
+ #close!: (err: Error | null) => void;
+ #answer!: () => void;
+ #refuse!: (err: Error) => void;
+
+ constructor(token: Uint8Array, setup: boolean) {
+ this.token = token;
+ this.setup = setup;
+ this.closed = new Promise((resolve) => {
+ this.#close = resolve;
+ });
+ this.answered = new Promise((resolve, reject) => {
+ this.#answer = resolve;
+ this.#refuse = reject;
+ });
+ // A caller that never awaits the answer must not see an unhandled rejection.
+ this.answered.catch(() => void 0);
+ }
+
+ answer() {
+ if (this.isAnswered) return;
+ this.isAnswered = true;
+ this.#answer();
+ }
+
+ end(err: Error | null) {
+ if (this.ended) return;
+ this.ended = true;
+ this.grant.set(undefined);
+ if (!this.isAnswered) {
+ this.isAnswered = true;
+ this.#refuse(err ?? new Error("withdrawn"));
+ }
+ this.#close(err);
+ }
+
+ close() {
+ if (this.withdrawn) return;
+ this.withdrawn = true;
+ // The stream's loop notices and ends the token; one still opening checks on arrival.
+ this.stream?.abort(cancel());
+ }
+}
+
+/** The peer's token, answered by the application. */
+class PeerRequest implements Request {
+ readonly token: Uint8Array;
+ #issued: IssuedGrant;
+ #answered = false;
+
+ constructor(token: Uint8Array, issued: IssuedGrant) {
+ this.token = token;
+ this.#issued = issued;
+ }
+
+ accept(grant: Grant): Issued {
+ if (this.#answered) throw new Error("already answered");
+ this.#answered = true;
+ this.#issued.update(grant);
+ return this.#issued;
+ }
+
+ reject(code: SessionCode, reason: string): void {
+ if (this.#answered) throw new Error("already answered");
+ this.#answered = true;
+ this.#issued.revoke(code, reason);
+ }
+}
+
+/** Our side of one of the peer's tokens: the grant we issued and its stream. */
+class IssuedGrant implements Issued {
+ readonly closed: Promise;
+ #stream: Stream;
+ #wire: AuthWire;
+ #writes = Promise.resolve();
+ #done = false;
+
+ constructor(stream: Stream, wire: AuthWire) {
+ this.#stream = stream;
+ this.#wire = wire;
+ // The presenter withdraws by closing or cancelling its side.
+ this.closed = stream.reader.closed.then(
+ () => null,
+ (err: unknown) => (err instanceof StreamError && err.code === StreamCode.Cancel ? null : error(err)),
+ );
+ }
+
+ #write(write: () => Promise) {
+ this.#writes = this.#writes.then(write).catch((err: unknown) => {
+ // The peer already closed the stream: nothing left to tell it.
+ if (err instanceof StreamError) return;
+ // A grant the wire cannot carry is withheld, never widened, and so is any other
+ // reply that fails to encode.
+ if (!(err instanceof Unsupported)) console.warn("auth reply not sent", err);
+ this.#done = true;
+ return this.#wire.unsupported(this.#stream, error(err).message).catch(() => void 0);
+ });
+ }
+
+ update(grant: Grant): void {
+ if (this.#done) return;
+ const expires = grant.expires === undefined ? undefined : grant.expires - Date.now();
+ this.#write(() =>
+ this.#wire.grant(this.#stream, { publish: grant.publish, subscribe: grant.subscribe, expires }),
+ );
+ }
+
+ revoke(code: SessionCode, reason: string): void {
+ if (this.#done) return;
+ this.#write(() => this.#wire.refuse(this.#stream, code, reason));
+ this.close();
+ }
+
+ close(): void {
+ if (this.#done) return;
+ this.#done = true;
+ this.#writes = this.#writes.then(() => this.#stream.writer.close());
+ }
+}
+
+/** The peer's tokens, queued for the application. */
+class RequestQueue implements Requests {
+ #queue: PeerRequest[] = [];
+ #waiters: ((request: PeerRequest | undefined) => void)[] = [];
+ #closed = false;
+
+ push(request: PeerRequest): boolean {
+ if (this.#closed) return false;
+ const waiter = this.#waiters.shift();
+ if (waiter) waiter(request);
+ else this.#queue.push(request);
+ return true;
+ }
+
+ next(): Promise {
+ const next = this.#queue.shift();
+ if (next || this.#closed) return Promise.resolve(next);
+ return new Promise((resolve) => this.#waiters.push(resolve));
+ }
+
+ close(): void {
+ this.#closed = true;
+ for (const request of this.#queue.splice(0)) {
+ request.reject(SessionCode.Unauthorized, "not accepting tokens");
+ }
+ for (const waiter of this.#waiters.splice(0)) waiter(undefined);
+ }
+}
+
+/** Constructor options for {@link AuthSession}. @internal */
+export interface AuthSessionProps {
+ /** How this session's wire carries the exchange, or undefined when it carries none. */
+ wire?: AuthWire;
+ /** What the default acceptor grants the peer's connection credential. */
+ peerGrant: Grant;
+}
+
+/**
+ * A session's tokens and grants: presents ours, one stream each, and answers the peer's.
+ * The wire decides only the encoding; see {@link AuthWire}.
+ *
+ * @internal
+ */
+export class AuthSession implements AuthApi {
+ #wire?: AuthWire;
+ #peerGrant: Grant;
+
+ #union = new Signal(undefined);
+ // The peer replied to some token, so the union is known even when empty.
+ #replied = false;
+ #tokens = new Set();
+ #setupPending = new Signal(0);
+ #acceptor: "undecided" | "default" | RequestQueue = "undecided";
+ #closed = false;
+
+ // Whoever answers the peer's tokens is decided once, after the task that established
+ // the session: an app that calls requests() as soon as connect/accept resolves always
+ // wins, however quickly the peer's first token arrives.
+ #decided = new Promise((resolve) => setTimeout(resolve, 0));
+
+ constructor({ wire, peerGrant }: AuthSessionProps) {
+ this.#wire = wire;
+ this.#peerGrant = peerGrant;
+
+ // Present the connection's own credential right away, so both sides learn their
+ // grant without waiting on the app.
+ if (wire) this.#present(wire, new Uint8Array(), true);
+ }
+
+ get grant(): Getter {
+ return this.#union;
+ }
+
+ /** Whether this session exchanges tokens at all. */
+ get negotiated(): boolean {
+ return this.#wire !== undefined;
+ }
+
+ async add(token: string | Uint8Array): Promise {
+ if (!this.#wire || this.#closed) throw new Unsupported();
+ const bytes = typeof token === "string" ? new TextEncoder().encode(token) : token;
+ const presented = this.#present(this.#wire, bytes, false);
+ await presented.answered;
+ return presented;
+ }
+
+ requests(): Requests {
+ if (this.#acceptor !== "undecided") throw new Error("auth requests already taken or answered by default");
+ const queue = new RequestQueue();
+ if (!this.#wire) queue.close();
+ this.#acceptor = queue;
+ return queue;
+ }
+
+ /** Resolves once every token the session presented at setup has its first reply. */
+ async setupAnswered(): Promise {
+ while (this.#setupPending.peek() > 0) await this.#setupPending.changed();
+ }
+
+ /** Answer one of the peer's token streams, for the life of its token. */
+ async serve(stream: Stream): Promise {
+ const wire = this.#wire;
+ if (!wire) throw new Error("auth not negotiated");
+
+ const token = await wire.accept(stream);
+ await this.#decided;
+ if (this.#acceptor === "undecided") this.#acceptor = "default";
+
+ const issued = new IssuedGrant(stream, wire);
+ if (this.#acceptor instanceof RequestQueue) {
+ const request = new PeerRequest(token, issued);
+ if (!this.#acceptor.push(request)) request.reject(SessionCode.Unauthorized, "not accepting tokens");
+ } else if (token.byteLength > 0) {
+ // Only the connection's own credential has a default answer; a token needs
+ // someone to verify it.
+ await wire.unsupported(stream, "no acceptor for tokens");
+ return;
+ } else {
+ issued.update(this.#peerGrant);
+ }
+
+ await issued.closed;
+ issued.close();
+ }
+
+ /** End the session: fail every pending token and close the requests. */
+ close() {
+ if (this.#closed) return;
+ this.#closed = true;
+ for (const token of this.#tokens) token.end(new Error("session closed"));
+ this.#tokens.clear();
+ this.#recompute();
+ if (this.#acceptor instanceof RequestQueue) this.#acceptor.close();
+ }
+
+ #present(wire: AuthWire, token: Uint8Array, setup: boolean): Presented {
+ const presented = new Presented(token, setup);
+ this.#tokens.add(presented);
+ if (setup) this.#setupPending.update((n) => n + 1);
+ void this.#run(wire, presented);
+ return presented;
+ }
+
+ async #run(wire: AuthWire, token: Presented) {
+ let result: Error | null = null;
+ try {
+ const stream = await wire.present(token.token);
+ token.stream = stream;
+ if (token.withdrawn) throw cancel();
+
+ for (;;) {
+ const reply = await wire.read(stream);
+ if (!reply) {
+ // The peer ended the grant without revoking it, or closed without ever
+ // answering. Close our side too, which is what settles its Issued.closed.
+ result = token.isAnswered ? null : new Unsupported();
+ stream.close();
+ break;
+ }
+ if ("grant" in reply) {
+ const expires = reply.grant.expires === undefined ? undefined : Date.now() + reply.grant.expires;
+ token.grant.set({ publish: reply.grant.publish, subscribe: reply.grant.subscribe, expires });
+ this.#replied = true;
+ this.#answered(token);
+ this.#recompute();
+ continue;
+ }
+ console.warn("auth token refused", reply.refused);
+ // A refused setup token leaves an empty union, not an unknown (unrestricted)
+ // one. A grant the acceptor could not tell is unknown, not empty.
+ if (!(reply.refused instanceof Unsupported)) this.#replied = true;
+ result = reply.refused;
+ stream.close();
+ break;
+ }
+ } catch (err: unknown) {
+ if (token.withdrawn) {
+ result = null;
+ } else if (!token.isAnswered && err instanceof StreamError) {
+ // A peer that predates AUTH resets a stream type it does not know.
+ result = new Unsupported();
+ } else {
+ result = error(err);
+ if (!this.#closed) console.warn("auth token ended", result);
+ }
+ }
+
+ // A token that ends unanswered counts as answered for enforcement: its refusal is
+ // the reply.
+ const unanswered = !token.isAnswered;
+ token.end(result);
+ if (unanswered && token.setup) this.#setupPending.update((n) => n - 1);
+ this.#tokens.delete(token);
+ this.#recompute();
+ }
+
+ #answered(token: Presented) {
+ if (token.isAnswered) return;
+ token.answer();
+ if (token.setup) this.#setupPending.update((n) => n - 1);
+ }
+
+ #recompute() {
+ const granted: Grant[] = [];
+ for (const token of this.#tokens) {
+ const grant = token.grant.peek();
+ if (grant) granted.push(grant);
+ }
+ // Undefined until the first reply; an empty union afterwards grants nothing.
+ if (granted.length === 0 && !this.#replied) return;
+ const next = union(granted);
+ if (!grantsEqual(next, this.#union.peek())) this.#union.set(next);
+ }
+}
+
+/**
+ * Close the session when our origin publishes a broadcast our grant does not cover,
+ * instead of leaving it to wait for a subscription that never comes.
+ *
+ * Starts once the tokens the session presented at setup are answered, then checks each
+ * broadcast when it first appears. A grant that later shrinks withdraws what it no longer
+ * covers without closing anything: the grant is read before the table, so a revocation is
+ * never mistaken for a new unauthorized publication. Only the dialing side enforces: a
+ * server's publish origin is everything the peer may read, not what it intends to push.
+ *
+ * @internal
+ */
+export async function enforceGrant({
+ quic,
+ advertised,
+ grant,
+ setupAnswered,
+}: {
+ quic: WebTransport;
+ /** The broadcasts this side publishes, undefined once the origin ends. */
+ advertised: Getter | undefined>;
+ grant: Getter;
+ setupAnswered: Promise;
+}): Promise {
+ const closed = quic.closed.then(
+ () => "closed" as const,
+ () => "closed" as const,
+ );
+ if ((await Promise.race([setupAnswered.then(() => "ready" as const), closed])) === "closed") return;
+
+ // Every broadcast admitted so far that is still published.
+ const live = new Set();
+ for (;;) {
+ let dispose: Dispose = () => {};
+ const woke = new Promise<"changed">((resolve) => {
+ const table = advertised.changed(() => resolve("changed"));
+ const granted = grant.changed(() => resolve("changed"));
+ dispose = () => {
+ table();
+ granted();
+ };
+ });
+
+ const current = grant.peek();
+ const table = advertised.peek();
+ if (!table) {
+ dispose();
+ return;
+ }
+ if (current) {
+ for (const path of live) {
+ if (!table.has(path)) live.delete(path);
+ }
+ for (const path of table.keys()) {
+ if (live.has(path)) continue;
+ if (!current.publish.matches(path)) {
+ console.error(`publishing outside our grant; closing the session: broadcast=${path}`);
+ quic.close({
+ closeCode: SessionCode.Unauthorized,
+ reason: closeReason(`unauthorized: ${path}`),
+ });
+ dispose();
+ return;
+ }
+ live.add(path);
+ }
+ }
+
+ const why = await Promise.race([woke, closed]);
+ dispose();
+ if (why === "closed") return;
+ }
+}
diff --git a/js/net/src/connection/accept.ts b/js/net/src/connection/accept.ts
index 89eedc2812..bf7df5be70 100644
--- a/js/net/src/connection/accept.ts
+++ b/js/net/src/connection/accept.ts
@@ -3,6 +3,7 @@ import * as Lite from "../lite/index.ts";
import type { Consumer as OriginConsumer, Producer as OriginProducer } from "../origin.ts";
import { Stream } from "../stream.ts";
import type { Established } from "./established.ts";
+import * as Extensions from "./extensions.ts";
import { forwardAnnounced } from "./forward.ts";
import { exchangeSetup } from "./handshake.ts";
@@ -33,12 +34,19 @@ export interface AcceptProps {
* nothing. The entries retract when the session dies; see the `consume` connect option.
*/
consume?: OriginProducer;
+
+ /** The moq-transport extensions to offer, each on unless set to `false`. */
+ extensions?: Extensions.Extensions;
}
/** The per-session wiring shared by every negotiated protocol path. */
type SessionProps = {
discovery: boolean;
publish?: OriginConsumer;
+ /** Whether this side dialed; only the dialing side aborts on a publication its grant does not cover. */
+ client: boolean;
+ /** The moq-transport extensions this side offers. */
+ extensions: Extensions.Offered;
};
/**
@@ -67,6 +75,8 @@ async function acceptInner(
const wiring: SessionProps = {
discovery: props.discovery ?? true,
publish: props.publish,
+ client: false,
+ extensions: Extensions.offered(props.extensions),
};
if (protocol === Ietf.ALPN.DRAFT_22) {
@@ -115,7 +125,12 @@ async function acceptAlpn(
version: Ietf.IetfVersion,
wiring: SessionProps,
): Promise {
- const { control, early, solicit, hidden, cluster } = await exchangeSetup(transport, version, "moq-lite-js");
+ const { control, early, solicit, hidden, cluster, auth } = await exchangeSetup(
+ transport,
+ version,
+ "moq-lite-js",
+ wiring.extensions,
+ );
return new Ietf.Connection({
...wiring,
@@ -127,6 +142,7 @@ async function acceptAlpn(
solicit,
hidden,
cluster,
+ auth,
// v17+ uses NativeSession which manages its own request IDs; maxRequestId is unused.
maxRequestId: 0n,
version,
@@ -160,7 +176,7 @@ async function acceptSetup(
const params = new Ietf.SetupOptions();
params.setVarint(Ietf.SetupOption.MaxRequestId, Ietf.initialMaxRequestId(true));
params.setBytes(Ietf.SetupOption.Implementation, encoder.encode("moq-lite-js"));
- Ietf.solicitIntoSetup(params);
+ Extensions.intoSetup(params, wiring.extensions, version);
Ietf.hiddenIntoSetup(params);
const server = new Ietf.ServerSetup({ version, parameters: params });
@@ -220,7 +236,7 @@ async function acceptNegotiated(
const params = new Ietf.SetupOptions();
params.setVarint(Ietf.SetupOption.MaxRequestId, Ietf.initialMaxRequestId(true));
params.setBytes(Ietf.SetupOption.Implementation, encoder.encode("moq-lite-js"));
- Ietf.solicitIntoSetup(params);
+ Extensions.intoSetup(params, wiring.extensions, setupVersion);
Ietf.hiddenIntoSetup(params);
const server = new Ietf.ServerSetup({ version: selectedVersion, parameters: params });
diff --git a/js/net/src/connection/connect.ts b/js/net/src/connection/connect.ts
index 980fbf40de..a482e1f699 100644
--- a/js/net/src/connection/connect.ts
+++ b/js/net/src/connection/connect.ts
@@ -9,6 +9,7 @@ import * as Hex from "../util/hex.ts";
import { redact } from "../util/log.ts";
import { isWebTransportSupported } from "./browser.ts";
import type { Established } from "./established.ts";
+import * as Extensions from "./extensions.ts";
import { forwardAnnounced } from "./forward.ts";
import { exchangeSetup } from "./handshake.ts";
@@ -110,6 +111,9 @@ export interface ConnectProps {
*/
consume?: OriginProducer;
+ /** The moq-transport extensions to offer, each on unless set to `false`. */
+ extensions?: Extensions.Extensions;
+
/**
* Aborts the connection attempt with the signal's reason. An already-aborted
* signal rejects before anything opens, and aborting after the connection is
@@ -122,6 +126,10 @@ export interface ConnectProps {
type SessionProps = {
discovery: boolean;
publish?: OriginConsumer;
+ /** Whether this side dialed; only the dialing side aborts on a publication its grant does not cover. */
+ client: boolean;
+ /** The moq-transport extensions this side offers. */
+ extensions: Extensions.Offered;
};
// Save if WebSocket won the last race, so we won't give QUIC a head start next time.
@@ -167,6 +175,8 @@ async function connectInner(url: URL, props: Omit, abort: P
const wiring: SessionProps = {
discovery: props.discovery ?? true,
publish: props.publish,
+ client: true,
+ extensions: Extensions.offered(props.extensions),
};
if (props.transport) {
@@ -304,7 +314,7 @@ async function negotiate(url: URL, session: WebTransport, wiring: SessionProps):
const params = new Ietf.SetupOptions();
params.setVarint(Ietf.SetupOption.MaxRequestId, Ietf.initialMaxRequestId(false));
params.setBytes(Ietf.SetupOption.Implementation, encoder.encode("moq-lite-js"));
- Ietf.solicitIntoSetup(params);
+ Extensions.intoSetup(params, wiring.extensions, setupVersion);
Ietf.hiddenIntoSetup(params);
const client = new Ietf.ClientSetup({
@@ -361,7 +371,12 @@ async function handshakeAlpn(
version: Ietf.IetfVersion,
wiring: SessionProps,
): Promise {
- const { control, early, solicit, hidden, cluster } = await exchangeSetup(session, version, "moq-lite-js");
+ const { control, early, solicit, hidden, cluster, auth } = await exchangeSetup(
+ session,
+ version,
+ "moq-lite-js",
+ wiring.extensions,
+ );
return new Ietf.Connection({
...wiring,
@@ -373,6 +388,7 @@ async function handshakeAlpn(
solicit,
hidden,
cluster,
+ auth,
// v17+ uses NativeSession which manages its own request IDs; maxRequestId is unused.
maxRequestId: 0n,
version,
diff --git a/js/net/src/connection/established.ts b/js/net/src/connection/established.ts
index 0c6200bda2..5c7db2aa2d 100644
--- a/js/net/src/connection/established.ts
+++ b/js/net/src/connection/established.ts
@@ -1,5 +1,6 @@
import type { Getter } from "@moq/signals";
import type * as announce from "../announced.ts";
+import type * as Auth from "../auth.ts";
import type * as Path from "../path.ts";
import type { Probe, Stats } from "./stats.ts";
import type { Transport } from "./transport.ts";
@@ -26,6 +27,14 @@ export interface Established {
*/
readonly probe: Getter;
+ /**
+ * The tokens this side presented and the grant they earned, plus the tokens the peer
+ * presents. On moq-lite-06, and on moq-transport draft-17+ when both sides negotiate
+ * MoQ Auth, each side presents its connection's credential right after setup.
+ * Otherwise the grant stays undefined.
+ */
+ readonly auth: Auth.Auth;
+
/**
* Whether the relay supports broadcast discovery: announcing which broadcasts exist under a
* prefix. When false, {@link announced} never yields, so a consumer must subscribe blind
diff --git a/js/net/src/connection/extensions.test.ts b/js/net/src/connection/extensions.test.ts
new file mode 100644
index 0000000000..60f19e8982
--- /dev/null
+++ b/js/net/src/connection/extensions.test.ts
@@ -0,0 +1,105 @@
+import { expect, spyOn, test } from "bun:test";
+import { SessionCode } from "../error.ts";
+import { AuthMessage, AuthOk } from "../ietf/auth.ts";
+import { PublishNamespace } from "../ietf/publish_namespace.ts";
+import { RequestOk } from "../ietf/request.ts";
+import { ALPN, Version } from "../ietf/version.ts";
+import { createMockTransportPair } from "../mock.ts";
+import * as Path from "../path.ts";
+import { Stream } from "../stream.ts";
+import { accept } from "./accept.ts";
+import { type Extensions, offered } from "./extensions.ts";
+import { exchangeSetup } from "./handshake.ts";
+
+const VERSION = Version.DRAFT_18;
+const URL_ = new URL("https://example.com");
+
+/** What each side learns from the other's SETUP when each offers `client` and `server`. */
+async function handshake(client: Extensions, server: Extensions) {
+ const pair = createMockTransportPair(ALPN.DRAFT_18);
+ const [dialed, accepted] = await Promise.all([
+ exchangeSetup(pair.client, VERSION, "test", offered(client)),
+ exchangeSetup(pair.server, VERSION, "test", offered(server)),
+ ]);
+ return { dialed, accepted };
+}
+
+/** Accept a draft-18 session offering `extensions`, against a peer offering every extension. */
+async function acceptWith(extensions: Extensions) {
+ const pair = createMockTransportPair(ALPN.DRAFT_18);
+ const [peer, connection] = await Promise.all([
+ exchangeSetup(pair.client, VERSION, "test", offered()),
+ accept({ transport: pair.server, url: URL_, extensions }),
+ ]);
+ return { pair, peer, connection };
+}
+
+test("every extension is offered by default", () => {
+ expect(offered()).toEqual({ auth: true, solicit: true });
+ expect(offered({ solicit: false })).toEqual({ auth: true, solicit: false });
+});
+
+test("a declined extension stays out of SETUP", async () => {
+ // Control: both sides offer both.
+ const all = await handshake({}, {});
+ expect(all.accepted.solicit).toBe(true);
+ expect(all.accepted.auth).toBe(true);
+ expect(all.dialed.auth).toBe(true);
+
+ const none = await handshake({ auth: false, solicit: false }, {});
+ expect(none.accepted.solicit).toBeUndefined();
+ expect(none.accepted.auth).toBe(false);
+ // Negotiated only when both offer it, so the declining side does not speak it either.
+ expect(none.dialed.auth).toBe(false);
+ expect(none.dialed.solicit).toBe(true);
+});
+
+test("an AUTH to a side that declined it closes the session", async () => {
+ // Control: offered by both, the connection credential is granted.
+ const offering = await acceptWith({});
+ const granted = await Stream.open(offering.pair.client, { version: VERSION });
+ await new AuthMessage(0n, new Uint8Array()).encode(granted.writer, VERSION);
+ expect(await granted.reader.u53()).toBe(AuthOk.id);
+ offering.connection.close();
+
+ const logged = spyOn(console, "error").mockImplementation(() => void 0);
+ try {
+ const declined = await acceptWith({ auth: false });
+ const refused = await Stream.open(declined.pair.client, { version: VERSION });
+ // The session closes on the message type alone, so the rest of the write may fail.
+ await new AuthMessage(0n, new Uint8Array()).encode(refused.writer, VERSION).catch(() => undefined);
+ const info = await declined.pair.client.closed;
+ expect(info.closeCode).toBe(SessionCode.ProtocolViolation);
+ } finally {
+ logged.mockRestore();
+ }
+});
+
+test("a side that declined solicit takes an unsolicited announce", async () => {
+ const announce = async (session: Awaited>) => {
+ const stream = await Stream.open(session.pair.client, { version: VERSION });
+ await stream.writer.u53(PublishNamespace.id);
+ await new PublishNamespace({
+ requestId: 0n,
+ trackNamespace: Path.from("room"),
+ cluster: { hops: [session.peer.cluster.self], cost: 0n },
+ }).encode(stream.writer, VERSION);
+ return stream;
+ };
+
+ const declined = await acceptWith({ solicit: false });
+ const taken = await announce(declined);
+ expect(await taken.reader.u53()).toBe(RequestOk.id);
+ declined.connection.close();
+
+ // Control: our SETUP declared it, so the peer that implements it broke it.
+ const logged = spyOn(console, "error").mockImplementation(() => void 0);
+ try {
+ const declaring = await acceptWith({});
+ await announce(declaring);
+ await declaring.pair.client.closed;
+ expect(logged).toHaveBeenCalled();
+ } finally {
+ logged.mockRestore();
+ }
+});
diff --git a/js/net/src/connection/extensions.ts b/js/net/src/connection/extensions.ts
new file mode 100644
index 0000000000..e029f37b47
--- /dev/null
+++ b/js/net/src/connection/extensions.ts
@@ -0,0 +1,27 @@
+import * as Ietf from "../ietf/index.ts";
+
+/**
+ * The moq-transport extensions a session offers in its SETUP, each on unless set to `false`.
+ * Turning one off connects as a peer that does not speak it. moq-lite carries both in its
+ * core, so this only changes moq-transport sessions.
+ */
+export interface Extensions {
+ /** The MoQ Auth extension: tokens presented and granted on their own stream (draft-17+). */
+ auth?: boolean;
+ /** The MoQ Solicit extension: the peer answers our SUBSCRIBE_NAMESPACE rather than announcing unasked. */
+ solicit?: boolean;
+}
+
+/** Every extension resolved to whether it is offered. */
+export type Offered = Required;
+
+/** Resolve each unset extension to offered. */
+export function offered(extensions?: Extensions): Offered {
+ return { auth: extensions?.auth ?? true, solicit: extensions?.solicit ?? true };
+}
+
+/** Write the options for the offered extensions into a SETUP. */
+export function intoSetup(params: Ietf.SetupOptions, extensions: Offered, version: Ietf.IetfVersion) {
+ if (extensions.solicit) Ietf.solicitIntoSetup(params);
+ if (extensions.auth) Ietf.Auth.intoSetup(params, version);
+}
diff --git a/js/net/src/connection/handshake.ts b/js/net/src/connection/handshake.ts
index 81bb2052a9..2903de9da6 100644
--- a/js/net/src/connection/handshake.ts
+++ b/js/net/src/connection/handshake.ts
@@ -2,6 +2,7 @@ import { StreamError } from "../error.ts";
import { type Hop, randomHop } from "../hop.ts";
import * as Ietf from "../ietf/index.ts";
import { Reader, Stream, UnexpectedEnd, Writer } from "../stream.ts";
+import * as Extensions from "./extensions.ts";
/**
* Draft-17+ SETUP exchange. Each side opens a uni stream, writes its Setup
@@ -12,26 +13,29 @@ import { Reader, Stream, UnexpectedEnd, Writer } from "../stream.ts";
* Returns the control stream, the uni streams that arrived before it (see
* {@link receiveSetup}), plus what the peer's SETUP declared: whether it requires
* solicitation, which decides whether we announce namespaces unprompted (see the MoQ Solicit
- * extension), and its Hop ID (see the MoQ Cluster extension). We declare both ourselves on
- * every session: we send SUBSCRIBE_NAMESPACE for each prefix we want, so an unsolicited
- * advertisement can tell us nothing we won't have asked for, and a peer that knows our Hop
- * ID can withhold the advertisements that already flowed through us.
+ * extension), its Hop ID (see the MoQ Cluster extension), and whether MoQ Auth is negotiated.
+ * We declare our Hop ID on every session, and Solicit and Auth unless `extensions` declines
+ * them: we send SUBSCRIBE_NAMESPACE for each prefix we want, so an unsolicited advertisement
+ * can tell us nothing we won't have asked for, and a peer that knows our Hop ID can withhold
+ * the advertisements that already flowed through us.
*/
export async function exchangeSetup(
transport: WebTransport,
version: Ietf.IetfVersion,
implementation: string,
+ extensions: Extensions.Offered,
): Promise<{
control: Stream;
early: Reader[];
solicit: boolean | undefined;
hidden: boolean;
cluster: Ietf.Cluster.Hops;
+ auth: boolean;
}> {
const encoder = new TextEncoder();
const params = new Ietf.SetupOptions();
params.setBytes(Ietf.SetupOption.Implementation, encoder.encode(implementation));
- Ietf.solicitIntoSetup(params);
+ Extensions.intoSetup(params, extensions, version);
Ietf.hiddenIntoSetup(params);
// One id per session, like the moq-lite connection: nothing in this process forwards
@@ -52,6 +56,8 @@ export async function exchangeSetup(
solicit: received.solicit,
hidden: received.hidden,
cluster: { self, peer: received.cluster },
+ // Negotiated only when both sides offer it.
+ auth: received.auth && extensions.auth,
};
}
@@ -79,6 +85,7 @@ async function receiveSetup(
solicit: boolean | undefined;
hidden: boolean;
cluster: Hop | undefined;
+ auth: boolean;
}> {
const uniReader = transport.incomingUnidirectionalStreams.getReader() as ReadableStreamDefaultReader<
ReadableStream
@@ -120,5 +127,6 @@ async function receiveSetup(
solicit: Ietf.solicitFromSetup(setup.parameters),
hidden: Ietf.hiddenFromSetup(setup.parameters),
cluster: Ietf.Cluster.fromSetup(setup.parameters, version),
+ auth: Ietf.Auth.fromSetup(setup.parameters, version) === true,
};
}
diff --git a/js/net/src/connection/index.ts b/js/net/src/connection/index.ts
index 9989292eb6..1cbbb3762c 100644
--- a/js/net/src/connection/index.ts
+++ b/js/net/src/connection/index.ts
@@ -14,6 +14,7 @@ export {
type WebTransportProps,
} from "./connect.ts";
export type { Established } from "./established.ts";
+export type { Extensions } from "./extensions.ts";
export { Connection, type ConnectionProps } from "./pool.ts";
export type { Probe, Stats } from "./stats.ts";
export type { Transport } from "./transport.ts";
diff --git a/js/net/src/connection/pool.ts b/js/net/src/connection/pool.ts
index c2e939d7fa..21a8613bf4 100644
--- a/js/net/src/connection/pool.ts
+++ b/js/net/src/connection/pool.ts
@@ -20,6 +20,7 @@ import {
type WebTransportProps as WebTransportPropsType,
} from "./connect.ts";
import type { Established as EstablishedType } from "./established.ts";
+import type { Extensions as ExtensionsType } from "./extensions.ts";
import type { GoawayProps, Redirect as RedirectType } from "./goaway.ts";
import { Reload, type ReloadDelay, type ReloadStatus } from "./reload.ts";
import type { Probe as ProbeType, Stats as StatsType } from "./stats.ts";
@@ -372,6 +373,8 @@ export namespace Connection {
export type CertificateHash = CertificateHashType;
/** An established one-shot session. */
export type Established = EstablishedType;
+ /** The moq-transport extensions a one-shot session offers. */
+ export type Extensions = ExtensionsType;
}
/** Throw if `props` cannot be honored, rather than silently dropping them. */
diff --git a/js/net/src/error.test.ts b/js/net/src/error.test.ts
index 210d39efa7..45b3566829 100644
--- a/js/net/src/error.test.ts
+++ b/js/net/src/error.test.ts
@@ -209,7 +209,10 @@ test("the code tables match the spec", () => {
expect(Number(SessionCode.Cancel)).toBe(0x0);
expect(Number(SessionCode.Unauthorized)).toBe(0x2);
expect(Number(SessionCode.GoawayTimeout)).toBe(0x10);
+ expect(Number(SessionCode.AuthTokenCacheOverflow)).toBe(0x13);
expect(Number(SessionCode.Version)).toBe(0x15);
+ expect(Number(SessionCode.UnknownAuthTokenAlias)).toBe(0x17);
+ expect(Number(SessionCode.TooManyRequestUpdates)).toBe(0x1b);
expect(Number(StreamCode.Cancel)).toBe(0x1);
expect(Number(StreamCode.SessionClosed)).toBe(0x3);
expect(Number(StreamCode.TooFarBehind)).toBe(0x5);
@@ -230,6 +233,7 @@ test("the code tables match the spec", () => {
StreamCode.Unroutable,
StreamCode.FrameTooLarge,
StreamCode.TimestampMismatch,
+ StreamCode.Unauthorized,
];
for (const code of Object.values(StreamCode)) {
if (assignedLite.includes(code)) {
@@ -248,6 +252,7 @@ test("the code tables match the spec", () => {
expect(Number(StreamCode.Unroutable)).toBe(0x36);
expect(Number(StreamCode.FrameTooLarge)).toBe(0x38);
expect(Number(StreamCode.TimestampMismatch)).toBe(0x39);
+ expect(Number(StreamCode.Unauthorized)).toBe(0x3b);
// The spaces are disjoint: 0 ends a session cleanly but fails a stream.
expect(Number(SessionCode.Cancel)).not.toBe(Number(StreamCode.Cancel));
diff --git a/js/net/src/error.ts b/js/net/src/error.ts
index f13d7dc536..d1dccb1a9f 100644
--- a/js/net/src/error.ts
+++ b/js/net/src/error.ts
@@ -44,8 +44,14 @@ export const SessionCode = Object.freeze(
GoawayTimeout: 0x10 as SessionCode,
/** A control message took too long. */
Timeout: 0x11 as SessionCode,
+ /** A token registration would exceed the advertised token cache size, which is 0 when unadvertised. */
+ AuthTokenCacheOverflow: 0x13 as SessionCode,
/** No version could be negotiated. */
Version: 0x15 as SessionCode,
+ /** A token named an alias that was never registered. */
+ UnknownAuthTokenAlias: 0x17 as SessionCode,
+ /** The peer left more request updates unanswered than MAX_REQUEST_UPDATES allows. */
+ TooManyRequestUpdates: 0x1b as SessionCode,
} as const),
);
@@ -96,6 +102,8 @@ export const StreamCode = Object.freeze(
FrameTooLarge: 0x38 as StreamCode,
/** The broadcast is neither announced nor served, so there is no route to it. */
Unroutable: 0x36 as StreamCode,
+ /** The grant does not cover this request, or no longer does. The session stays up. */
+ Unauthorized: 0x3b as StreamCode,
/** A group grew past its cache budget and was aborted. */
GroupTooLarge: 0x32 as StreamCode,
/** A frame's timedness or timestamp doesn't match its track's timescale. */
@@ -294,9 +302,13 @@ export class RefusedRedirect extends Error {
* @public
*/
export class ProtocolViolation extends Error {
- constructor(message: string, options?: { cause?: unknown }) {
+ /** The session code to close with: `ProtocolViolation` unless the draft names a more specific one. */
+ readonly code: SessionCode;
+
+ constructor(message: string, options?: { cause?: unknown; code?: SessionCode }) {
super(message, options);
this.name = "ProtocolViolation";
+ this.code = options?.code ?? SessionCode.ProtocolViolation;
}
}
@@ -370,6 +382,16 @@ export function controlTimeout(cause: unknown): Stream {
return new Stream(StreamCode.ControlTimeout, { cause, message });
}
+/**
+ * The {@link StreamCode.Unauthorized} error for a request the grant does not cover, naming
+ * the broadcast. Unlike {@link SessionCode.Unauthorized}, it ends only this stream.
+ *
+ * @internal
+ */
+export function unauthorized(broadcast: string): Stream {
+ return new Stream(StreamCode.Unauthorized, { message: `unauthorized: ${broadcast}` });
+}
+
/**
* Decode a transport failure into a {@link Stream} when it carries a stream reset code,
* otherwise pass it through.
@@ -463,6 +485,24 @@ export function fromClose(info: WebTransportCloseInfo): Session | null {
return new Session(code, { reason: info.reason });
}
+// WebTransport rejects a close reason over 1024 bytes of UTF-8 by throwing, so a reason
+// built from peer-supplied data has to be bounded before it gets there. A broadcast path
+// is peer-supplied and long enough to reach this on its own.
+const MAX_CLOSE_REASON = 1024;
+
+/**
+ * The longest prefix of `text` that fits a session close reason. `encodeInto` stops on a
+ * whole code point, so `read` never lands mid-character the way slicing bytes would.
+ *
+ * @internal
+ */
+export function closeReason(text: string): string {
+ const encoder = new TextEncoder();
+ const buf = new Uint8Array(MAX_CLOSE_REASON);
+ const { read } = encoder.encodeInto(text, buf);
+ return text.slice(0, read);
+}
+
/**
* The session's close as the error it ends everything with, carrying the peer's code. A clean
* close code is still an error here: whatever the close cut off did not end.
diff --git a/js/net/src/ietf/auth.test.ts b/js/net/src/ietf/auth.test.ts
new file mode 100644
index 0000000000..2ebcd217c7
--- /dev/null
+++ b/js/net/src/ietf/auth.test.ts
@@ -0,0 +1,135 @@
+import { expect, test } from "bun:test";
+import { Unsupported } from "../auth.ts";
+import { SessionError } from "../error.ts";
+import * as Path from "../path.ts";
+import { Reader, Writer } from "../stream.ts";
+import { AuthError, AuthMessage, AuthOk, fromSetup, intoSetup } from "./auth.ts";
+import { SetupOption, SetupOptions } from "./parameters.ts";
+import { Version } from "./version.ts";
+
+function patterns(...prefixes: string[]): Path.Patterns {
+ return new Path.Patterns(prefixes.map((prefix) => Path.Pattern.subtree(prefix)));
+}
+
+/** The bytes a write produces. */
+async function bytes(write: (w: Writer) => Promise): Promise {
+ const chunks: Uint8Array[] = [];
+ const writer = new Writer(
+ new WritableStream({
+ write(chunk) {
+ chunks.push(new Uint8Array(chunk));
+ },
+ }),
+ Version.DRAFT_17,
+ );
+ await write(writer);
+ writer.close();
+ await writer.closed;
+ const out = new Uint8Array(chunks.reduce((n, c) => n + c.byteLength, 0));
+ let offset = 0;
+ for (const chunk of chunks) {
+ out.set(chunk, offset);
+ offset += chunk.byteLength;
+ }
+ return out;
+}
+
+/** A reader past the message type, the way the dispatcher leaves one. */
+async function afterType(write: (w: Writer) => Promise): Promise {
+ const reader = new Reader(undefined, await bytes(write), Version.DRAFT_17);
+ await reader.u53();
+ return reader;
+}
+
+test("the setup option negotiates on draft-17+ only", () => {
+ for (const version of [Version.DRAFT_17, Version.DRAFT_22]) {
+ const params = new SetupOptions();
+ expect(fromSetup(params, version)).toBeUndefined();
+ intoSetup(params, version);
+ expect(fromSetup(params, version)).toBe(true);
+ }
+ const legacy = new SetupOptions();
+ intoSetup(legacy, Version.DRAFT_16);
+ expect(legacy.getVarint(SetupOption.Auth)).toBeUndefined();
+
+ // An explicit value other than 1 is an implementation that declined.
+ const declined = new SetupOptions();
+ declined.setVarint(SetupOption.Auth, 0n);
+ expect(fromSetup(declined, Version.DRAFT_17)).toBe(false);
+});
+
+test("AUTH round-trips its request id and token", async () => {
+ const r = await afterType((w) => new AuthMessage(4n, new TextEncoder().encode("jwt")).encode(w, Version.DRAFT_17));
+ const msg = await AuthMessage.decode(r, Version.DRAFT_17);
+ expect(msg.requestId).toBe(4n);
+ expect(new TextDecoder().decode(msg.token)).toBe("jwt");
+});
+
+test("AUTH_OK carries prefixes as namespace tuples", async () => {
+ const ok = new AuthOk(patterns("room/alice"), patterns(), undefined);
+ const wire = await bytes((w) => ok.encode(w, Version.DRAFT_17));
+ // Type, 16-bit length, then: one prefix of two fields, no subscribe prefixes, never expires.
+ expect([...wire.slice(-15)]).toEqual([
+ 0x01,
+ 0x02,
+ 0x04,
+ ...new TextEncoder().encode("room"),
+ 0x05,
+ ...new TextEncoder().encode("alice"),
+ 0x00,
+ 0x00,
+ ]);
+
+ const r = await afterType((w) => new AuthOk(patterns(""), patterns("room"), 60_000).encode(w, Version.DRAFT_17));
+ const got = await AuthOk.decode(r, Version.DRAFT_17);
+ // The empty prefix grants everything; the empty list grants nothing.
+ expect(got.publish.equals(new Path.Patterns([Path.Pattern.all()]))).toBe(true);
+ expect(got.subscribe.equals(patterns("room"))).toBe(true);
+ expect(got.expires).toBe(60_000);
+});
+
+test("a grant the prefix wire cannot express is refused before anything is written", async () => {
+ for (const union of [["room/alice"], ["room/*/cam"], ["room/**", "lobby"]]) {
+ const narrow = new AuthOk(new Path.Patterns(union.map((p) => Path.Pattern.parse(p))), patterns());
+ let written = 0;
+ const writer = new Writer(
+ new WritableStream({
+ write(chunk) {
+ written += chunk.byteLength;
+ },
+ }),
+ Version.DRAFT_17,
+ );
+ await expect(narrow.encode(writer, Version.DRAFT_17)).rejects.toBeInstanceOf(Unsupported);
+ writer.close();
+ await writer.closed;
+ expect(written).toBe(0);
+ }
+});
+
+test("a grant too large for one message is refused before anything is written", async () => {
+ const huge = new AuthOk(patterns(...Array.from({ length: 20 }, (_, i) => `${i}${"x".repeat(4000)}`)), patterns());
+ let written = 0;
+ const writer = new Writer(
+ new WritableStream({
+ write(chunk) {
+ written += chunk.byteLength;
+ },
+ }),
+ Version.DRAFT_17,
+ );
+ await expect(huge.encode(writer, Version.DRAFT_17)).rejects.toThrow("Message too large");
+ writer.close();
+ await writer.closed;
+ expect(written).toBe(0);
+});
+
+test("NOT_SUPPORTED is unsupported, every other code a refusal", async () => {
+ const r = await afterType((w) => new AuthError(0x3, "prefixes only").encode(w, Version.DRAFT_17));
+ expect((await AuthError.decode(r, Version.DRAFT_17)).toError()).toBeInstanceOf(Unsupported);
+ expect(new AuthError(0x1, "bad").toError()).toBeInstanceOf(SessionError);
+});
+
+test("drafts before 17 carry no AUTH", async () => {
+ await expect(bytes((w) => new AuthMessage(0n, new Uint8Array()).encode(w, Version.DRAFT_16))).rejects.toThrow();
+});
diff --git a/js/net/src/ietf/auth.ts b/js/net/src/ietf/auth.ts
new file mode 100644
index 0000000000..672b19db5e
--- /dev/null
+++ b/js/net/src/ietf/auth.ts
@@ -0,0 +1,274 @@
+import { Unsupported } from "../auth.ts";
+import type { AuthWire, WireGrant, WireReply } from "../auth_session.ts";
+import { SessionCode, SessionError } from "../error.ts";
+import * as Path from "../path.ts";
+import type { Reader, Stream, Writer } from "../stream.ts";
+import * as Message from "./message.ts";
+import * as Namespace from "./namespace.ts";
+import { SetupOption, type SetupOptions } from "./parameters.ts";
+import { type IetfVersion, Version } from "./version.ts";
+
+/**
+ * The MoQ Auth extension (draft-lcurley-moq-auth-00): the moq-lite Auth Stream as
+ * moq-transport request streams, negotiated with the AUTH Setup Option on draft-17+.
+ *
+ * The wire carries namespace prefixes, so a grant is told only when it is a union of
+ * subtrees; anything narrower is refused with NOT_SUPPORTED rather than widened.
+ *
+ * @module
+ * @internal
+ */
+
+/** Whether a version negotiates the extension. @internal */
+export function supported(version: IetfVersion): boolean {
+ return version >= Version.DRAFT_17;
+}
+
+/**
+ * What the peer declared: undefined for no option, otherwise whether it offered the
+ * extension. Only an explicit 1 negotiates it.
+ *
+ * @internal
+ */
+export function fromSetup(params: SetupOptions, version: IetfVersion): boolean | undefined {
+ if (!supported(version)) return undefined;
+ const value = params.getVarint(SetupOption.Auth);
+ return value === undefined ? undefined : value === 1n;
+}
+
+/** Offer the extension, on the versions that negotiate it. @internal */
+export function intoSetup(params: SetupOptions, version: IetfVersion) {
+ if (supported(version)) params.setVarint(SetupOption.Auth, 1n);
+}
+
+/** The REQUEST_ERROR codes AUTH_ERROR carries. */
+const UNAUTHORIZED = 0x1;
+const NOT_SUPPORTED = 0x3;
+
+/** Longest AUTH_ERROR reason, in bytes, matching the Rust decoder. */
+const MAX_REASON = 8192;
+
+function guard(version: IetfVersion) {
+ if (!supported(version)) throw new Error("auth not supported for this version");
+}
+
+/**
+ * AUTH: the first message on an Auth request stream, presenting a token. Each message
+ * here encodes its own type, which the dispatcher consumes before decoding.
+ *
+ * @internal
+ */
+export class AuthMessage {
+ static id = 0x40b61;
+
+ requestId: bigint;
+ token: Uint8Array;
+
+ constructor(requestId: bigint, token: Uint8Array) {
+ this.requestId = requestId;
+ this.token = token;
+ }
+
+ async encode(w: Writer, version: IetfVersion): Promise {
+ guard(version);
+ return Message.encode(
+ w,
+ async (wr) => {
+ await wr.u62(this.requestId);
+ await wr.u53(this.token.byteLength);
+ if (this.token.byteLength > 0) await wr.write(this.token);
+ },
+ AuthMessage.id,
+ );
+ }
+
+ static async decode(r: Reader, version: IetfVersion): Promise {
+ guard(version);
+ return Message.decode(r, async (rd) => {
+ const requestId = await rd.u62();
+ const size = await rd.u53();
+ return new AuthMessage(requestId, await rd.read(size));
+ });
+ }
+}
+
+/** Each pattern as a namespace prefix, or throw {@link Unsupported} for one that is not a subtree. */
+function prefixes(patterns: Path.Patterns): Path.Valid[] {
+ return patterns.toArray().map((pattern) => {
+ const prefix = pattern.asPrefix();
+ if (prefix === undefined) throw new Unsupported(`grant not representable as namespace prefixes: ${pattern}`);
+ return prefix as Path.Valid;
+ });
+}
+
+async function encodePrefixes(w: Writer, list: Path.Valid[]) {
+ await w.u53(list.length);
+ for (const prefix of list) await Namespace.encode(w, prefix);
+}
+
+async function decodePrefixes(r: Reader): Promise {
+ const count = await r.u53();
+ const patterns = new Path.Patterns();
+ for (let i = 0; i < count; i++) {
+ patterns.insert(Path.Pattern.subtree(await Namespace.decode(r)));
+ }
+ return patterns;
+}
+
+/** AUTH_OK: the grant a token earns, replacing any earlier one on the stream. @internal */
+export class AuthOk {
+ static id = 0x40b62;
+
+ publish: Path.Patterns;
+ subscribe: Path.Patterns;
+ /** Milliseconds until the grant lapses, or undefined for never. */
+ expires?: number;
+
+ constructor(publish: Path.Patterns, subscribe: Path.Patterns, expires?: number) {
+ this.publish = publish;
+ this.subscribe = subscribe;
+ this.expires = expires;
+ }
+
+ async encode(w: Writer, version: IetfVersion): Promise {
+ guard(version);
+ // Resolved before anything is written, so an unrepresentable grant leaves nothing
+ // half-sent.
+ const publish = prefixes(this.publish);
+ const subscribe = prefixes(this.subscribe);
+ return Message.encode(
+ w,
+ async (wr) => {
+ await encodePrefixes(wr, publish);
+ await encodePrefixes(wr, subscribe);
+ // 0 means never, so a lapsed grant rounds up to the smallest real expiry.
+ const expires =
+ this.expires === undefined
+ ? 0
+ : Math.min(Math.max(Math.ceil(this.expires), 1), Number.MAX_SAFE_INTEGER);
+ await wr.u53(expires);
+ },
+ AuthOk.id,
+ );
+ }
+
+ static async decode(r: Reader, version: IetfVersion): Promise {
+ guard(version);
+ return Message.decode(r, async (rd) => {
+ const publish = await decodePrefixes(rd);
+ const subscribe = await decodePrefixes(rd);
+ const expires = await rd.u53();
+ return new AuthOk(publish, subscribe, expires === 0 ? undefined : expires);
+ });
+ }
+}
+
+/** AUTH_ERROR: the acceptor refusing a token, or revoking it after an AUTH_OK. @internal */
+export class AuthError {
+ static id = 0x40b63;
+
+ /** A code from the REQUEST_ERROR registry. */
+ code: number;
+ reason: string;
+
+ constructor(code: number, reason: string) {
+ this.code = code;
+ this.reason = reason;
+ }
+
+ async encode(w: Writer, version: IetfVersion): Promise {
+ guard(version);
+ if (new TextEncoder().encode(this.reason).byteLength > MAX_REASON) {
+ throw new Error("AUTH_ERROR reason exceeds 8,192 bytes");
+ }
+ return Message.encode(
+ w,
+ async (wr) => {
+ await wr.u53(this.code);
+ await wr.string(this.reason);
+ },
+ AuthError.id,
+ );
+ }
+
+ static async decode(r: Reader, version: IetfVersion): Promise {
+ guard(version);
+ return Message.decode(r, async (rd) => {
+ const code = await rd.u53();
+ const reason = await rd.string();
+ if (new TextEncoder().encode(reason).byteLength > MAX_REASON) {
+ throw new Error("AUTH_ERROR reason exceeds 8,192 bytes");
+ }
+ return new AuthError(code, reason);
+ });
+ }
+
+ /** What the refusal means to the presenter: NOT_SUPPORTED is a grant it could not tell. */
+ toError(): Error {
+ if (this.code === NOT_SUPPORTED) return new Unsupported(this.reason);
+ return new SessionError(SessionCode.Unauthorized, { reason: this.reason });
+ }
+}
+
+/** The moq-transport binding of the token lifecycle. @internal */
+export class IetfAuthWire implements AuthWire {
+ #openBi: () => Promise;
+ #nextRequestId: () => Promise;
+ #version: IetfVersion;
+
+ constructor(props: {
+ openBi: () => Promise;
+ nextRequestId: () => Promise;
+ version: IetfVersion;
+ }) {
+ this.#openBi = props.openBi;
+ this.#nextRequestId = props.nextRequestId;
+ this.#version = props.version;
+ }
+
+ async present(token: Uint8Array): Promise {
+ const requestId = await this.#nextRequestId();
+ if (requestId === undefined) throw new Error("no request id available");
+ const stream = await this.#openBi();
+ await new AuthMessage(requestId, token).encode(stream.writer, this.#version);
+ return stream;
+ }
+
+ async read(stream: Stream): Promise {
+ if (await stream.reader.done()) return undefined;
+ const id = await stream.reader.u53();
+ switch (id) {
+ case AuthOk.id: {
+ const ok = await AuthOk.decode(stream.reader, this.#version);
+ return { grant: { publish: ok.publish, subscribe: ok.subscribe, expires: ok.expires } };
+ }
+ case AuthError.id: {
+ const err = await AuthError.decode(stream.reader, this.#version);
+ return { refused: err.toError() };
+ }
+ default:
+ throw new Error(`unexpected message on an auth request: 0x${id.toString(16)}`);
+ }
+ }
+
+ /** The type id is already consumed by the dispatcher. */
+ async accept(stream: Stream): Promise {
+ const msg = await AuthMessage.decode(stream.reader, this.#version);
+ return msg.token;
+ }
+
+ async grant(stream: Stream, grant: WireGrant): Promise {
+ await new AuthOk(grant.publish, grant.subscribe, grant.expires).encode(stream.writer, this.#version);
+ }
+
+ async refuse(stream: Stream, code: SessionCode, reason: string): Promise {
+ // The public API speaks session codes; this registry distinguishes only a version mismatch.
+ const wire = code === SessionCode.Version ? NOT_SUPPORTED : UNAUTHORIZED;
+ await new AuthError(wire, reason).encode(stream.writer, this.#version);
+ }
+
+ async unsupported(stream: Stream, reason: string): Promise {
+ await new AuthError(NOT_SUPPORTED, reason).encode(stream.writer, this.#version);
+ await stream.writer.close();
+ }
+}
diff --git a/js/net/src/ietf/cluster.test.ts b/js/net/src/ietf/cluster.test.ts
index a4c2626c46..377384e527 100644
--- a/js/net/src/ietf/cluster.test.ts
+++ b/js/net/src/ietf/cluster.test.ts
@@ -241,7 +241,7 @@ test("Cluster: a negotiated PUBLISH_NAMESPACE may carry AUTHORIZATION_TOKEN", as
await wr.u62(1n);
await Namespace.encode(wr, Path.from("alice.hang"));
const params = Cluster.intoParams(advert);
- params.bytes.set(0x03n, new Uint8Array([1, 2, 3])); // AUTHORIZATION_TOKEN
+ params.bytes.set(0x03n, new Uint8Array([0x03, 0x00, 0xaa])); // AUTHORIZATION_TOKEN: USE_VALUE, type 0
await params.encode(wr, version);
});
},
diff --git a/js/net/src/ietf/cluster.ts b/js/net/src/ietf/cluster.ts
index 0af006eb97..21ed9b7480 100644
--- a/js/net/src/ietf/cluster.ts
+++ b/js/net/src/ietf/cluster.ts
@@ -222,6 +222,8 @@ export async function decodeParams(
try {
params = await Parameters.decode(r, version, message);
} catch (err) {
+ // Keep a violation's own code, such as a request token's.
+ if (err instanceof ProtocolViolation) throw err;
throw new ProtocolViolation(reason(err), { cause: err });
}
diff --git a/js/net/src/ietf/connection.test.ts b/js/net/src/ietf/connection.test.ts
index 306b9f7b2a..3eedb234e9 100644
--- a/js/net/src/ietf/connection.test.ts
+++ b/js/net/src/ietf/connection.test.ts
@@ -1,4 +1,5 @@
import { expect, spyOn, test } from "bun:test";
+import { offered } from "../connection/extensions.ts";
import { exchangeSetup } from "../connection/handshake.ts";
import { SessionCode } from "../error.ts";
import { createMockTransportPair } from "../mock.ts";
@@ -298,7 +299,7 @@ test("uni streams before SETUP are held until it lands", async () => {
parameters.setBytes(SetupOption.Implementation, new TextEncoder().encode("test"));
await new Setup({ parameters }).encode(setup, version);
- const { control, early, solicit, hidden, cluster } = await exchangeSetup(pair.server, version, "test");
+ const { control, early, solicit, hidden, cluster } = await exchangeSetup(pair.server, version, "test", offered());
expect(early.length).toBe(2);
let closed = false;
@@ -348,7 +349,7 @@ test("malformed uni stream before SETUP fails the handshake", async () => {
parameters.setBytes(SetupOption.Implementation, new TextEncoder().encode("test"));
await new Setup({ parameters }).encode(setup, version);
- await expect(exchangeSetup(pair.server, version, "test")).rejects.toThrow("leading-ones varint");
+ await expect(exchangeSetup(pair.server, version, "test", offered())).rejects.toThrow("leading-ones varint");
});
/** An early stream that ends partway through its type died; it is skipped like an empty one. */
@@ -367,7 +368,7 @@ test("truncated uni stream before SETUP is skipped", async () => {
parameters.setBytes(SetupOption.Implementation, new TextEncoder().encode("test"));
await new Setup({ parameters }).encode(setup, version);
- const { early } = await exchangeSetup(pair.server, version, "test");
+ const { early } = await exchangeSetup(pair.server, version, "test", offered());
expect(early.length).toBe(0);
});
@@ -497,3 +498,60 @@ for (const [version, alpn] of [
});
}
}
+
+/** A SUBSCRIBE for `room/video` whose only parameter is an AUTHORIZATION TOKEN holding `token`. */
+function subscribeWithToken(version: IetfVersion, token: readonly number[]): Uint8Array {
+ const ascii = (text: string) => [...new TextEncoder().encode(text)];
+ // Request ID, then the namespace and track name.
+ const head = [0, 1, 4, ...ascii("room"), 5, ...ascii("video")];
+ // Draft-14 carries priority, group order, forward, and a Largest Object filter inline.
+ const fields = version === Version.DRAFT_14 ? [0x80, 0x02, 0x01, 0x02] : [];
+ const body = [...head, ...fields, 1, 0x03, token.length, ...token];
+ return new Uint8Array([0x03, 0, body.length, ...body]);
+}
+
+/**
+ * A request's AUTHORIZATION TOKEN decodes by the SETUP option's rules, so one this endpoint
+ * cannot use closes the session with the draft's code. Draft-14 carries the request on the
+ * control stream, a strict draft on its own stream.
+ */
+for (const [version, alpn] of [
+ [Version.DRAFT_14, ALPN.DRAFT_14],
+ [Version.DRAFT_22, ALPN.DRAFT_22],
+] as const) {
+ for (const [name, token, code] of [
+ ["REGISTER", [0x01, 0x07, 0x00], SessionCode.AuthTokenCacheOverflow],
+ ["DELETE", [0x00, 0x07], SessionCode.UnknownAuthTokenAlias],
+ ["USE_ALIAS", [0x02, 0x07], SessionCode.UnknownAuthTokenAlias],
+ ["USE_VALUE without a type", [0x03], SessionCode.KeyValueFormatting],
+ ["an unknown alias type", [0x04, 0x00], SessionCode.KeyValueFormatting],
+ ] as const) {
+ test(`a request token with ${name} closes ${alpn}`, async () => {
+ const logged = spyOn(console, "error").mockImplementation(() => void 0);
+ const pair = createMockTransportPair(alpn);
+ const control = await Stream.open(pair.server, { version });
+ const connection = new Connection({
+ url: new URL("https://example.com"),
+ quic: pair.server,
+ control,
+ maxRequestId: 100n,
+ version,
+ client: false,
+ });
+ try {
+ const stream =
+ version === Version.DRAFT_14
+ ? await Stream.accept(pair.client, version)
+ : await Stream.open(pair.client, { version });
+ if (!stream) throw new Error("no control stream");
+ await stream.writer.write(subscribeWithToken(version, token));
+
+ const info = await pair.client.closed;
+ expect(info.closeCode).toBe(code);
+ } finally {
+ logged.mockRestore();
+ connection.abort();
+ }
+ });
+ }
+}
diff --git a/js/net/src/ietf/connection.ts b/js/net/src/ietf/connection.ts
index 6cf0994973..48ccfea5a4 100644
--- a/js/net/src/ietf/connection.ts
+++ b/js/net/src/ietf/connection.ts
@@ -1,16 +1,19 @@
import { type Getter, Once, Signal } from "@moq/signals";
import type * as announce from "../announced.ts";
+import type * as Auth from "../auth.ts";
+import { AuthSession } from "../auth_session.ts";
import type { Established } from "../connection/established.ts";
import type { Drain } from "../connection/goaway.ts";
import { type Probe, type Stats, transportStats } from "../connection/stats.ts";
import { type Transport, transportOf } from "../connection/transport.ts";
-import { error, fromClose, ProtocolViolation, SessionCode, StreamCode, StreamError } from "../error.ts";
+import { error, fromClose, ProtocolViolation, StreamCode, StreamError } from "../error.ts";
import type { Consumer as OriginConsumer } from "../origin.ts";
-import type * as Path from "../path.ts";
+import * as Path from "../path.ts";
import { type Reader, Readers, type Stream } from "../stream.ts";
import { withTimeout } from "../util/timeout.ts";
import { registerWire } from "../wire.ts";
import { ControlStreamAdapter, NativeSession, RequestWindowError, type Session } from "./adapter.ts";
+import { AuthMessage, supported as authSupported, IetfAuthWire } from "./auth.ts";
import * as Cluster from "./cluster.ts";
import { Fetch, FetchHeader } from "./fetch.ts";
import { GoAway } from "./goaway.ts";
@@ -54,10 +57,19 @@ export class Connection implements Established {
/** moq-transport has no PROBE, so this stays empty; see {@link Established.probe}. */
readonly probe: Getter = new Signal({});
+ /** Our tokens and grants, when the peer negotiated MoQ Auth; see {@link Established.auth}. */
+ get auth(): Auth.Auth {
+ return this.#auth;
+ }
+
+ // Our tokens and grants, and the answers to the peer's (MoQ Auth).
+ #auth: AuthSession;
+
// The established WebTransport session.
#quic: WebTransport;
- // Whether this side opened the session. Only a server may name a redirect.
+ // Whether this side opened the session. Only a server may name a redirect, and only
+ // the dialing side fails loud on a publication its grant does not cover.
#client: boolean;
// Session abstraction: adapter for v14-v16, native for v17.
@@ -71,6 +83,8 @@ export class Connection implements Established {
// What the peer declared about being solicited; see {@link Ietf.solicitFromSetup}.
#solicit: boolean | undefined;
+ // Whether our SETUP declared MoQ Solicit, which is what makes an unasked announce a fault.
+ #declaredSolicit: boolean;
// The Hop IDs this session declared; see {@link Cluster}.
#cluster?: Cluster.Hops;
@@ -106,8 +120,10 @@ export class Connection implements Established {
solicit,
hidden = false,
cluster,
+ auth = false,
early = [],
requestWindow,
+ extensions,
}: {
url: URL;
quic: WebTransport;
@@ -131,10 +147,14 @@ export class Connection implements Established {
* cannot negotiate the extension, as is a `peer` the peer never declared.
*/
cluster?: Cluster.Hops;
+ /** Whether MoQ Auth is negotiated (draft-17+): offered by both SETUPs. */
+ auth?: boolean;
/** Uni streams that arrived before the peer's SETUP, type unread (v17+). */
early?: Reader[];
/** Requests the peer may hold open on drafts 14 to 16, the window our SETUP advertised (default `REQUEST_WINDOW`). */
requestWindow?: bigint;
+ /** What our own SETUP offered; every extension when omitted. */
+ extensions?: { solicit: boolean };
}) {
this.url = url;
this.discovery = discovery;
@@ -165,6 +185,25 @@ export class Connection implements Established {
});
}
+ // What the peer's connection credential earns by default: publishing anything to us,
+ // since we consume on demand, and subscribing to whatever we publish.
+ const session = this.#session;
+ this.#auth = new AuthSession({
+ wire:
+ auth && authSupported(version)
+ ? new IetfAuthWire({
+ openBi: async () => session.openBi(),
+ nextRequestId: () => session.nextRequestId(),
+ version,
+ })
+ : undefined,
+ peerGrant: {
+ publish: new Path.Patterns([Path.Pattern.all()]),
+ subscribe: new Path.Patterns(publish ? [Path.Pattern.all()] : []),
+ },
+ });
+ this.#client = client;
+
this.#publisher = new Publisher({
quic: this.#quic,
session: this.#session,
@@ -172,8 +211,11 @@ export class Connection implements Established {
requiresSolicitation: solicit ?? false,
hidden,
cluster,
+ grant: this.#auth.grant,
+ ready: this.#auth.setupAnswered(),
});
this.#solicit = solicit;
+ this.#declaredSolicit = extensions?.solicit ?? true;
this.#cluster = cluster;
this.#subscriber = new Subscriber({
session: this.#session,
@@ -182,6 +224,7 @@ export class Connection implements Established {
hidden,
solicit,
goaway: this.#goaway,
+ grant: this.#auth.grant,
});
registerWire(this, { consume: (path) => this.#subscriber.consume(path), goaway: this.#goaway });
@@ -215,6 +258,8 @@ export class Connection implements Established {
this.#closed = true;
this.#publisher.close();
+ this.#auth.close();
+
// Before the session, whose own close would send a clean code first.
try {
this.#quic.close(info);
@@ -228,7 +273,7 @@ export class Connection implements Established {
// The peer broke the protocol, so losing the stream is not enough: nothing stops it
// repeating the violation on the next one.
#violated(err: ProtocolViolation) {
- this.#close({ closeCode: SessionCode.ProtocolViolation, reason: err.message });
+ this.#close({ closeCode: err.code, reason: err.message });
}
async #run(early: Reader[]): Promise {
@@ -237,12 +282,16 @@ export class Connection implements Established {
// solicited; otherwise it pushes PUBLISH_NAMESPACE. On draft-16 and later a
// SUBSCRIBE_NAMESPACE stream is filled either way. A NAMESPACE is discovery,
// not a second route.
- await Promise.all([
+ const tasks = [
this.#runBidis(),
this.#runUnis(early),
this.#runDatagrams(),
this.#publisher.runPublishNamespaces(),
- ]);
+ ];
+ // Fail loud on a publication our grant never covers, once the peer has answered the
+ // credential we presented at setup.
+ if (this.#client) tasks.push(this.#publisher.runEnforce(this.#auth.setupAnswered()));
+ await Promise.all(tasks);
} catch (err) {
if (!this.#closed) {
console.error("fatal error running connection", err);
@@ -362,18 +411,19 @@ export class Connection implements Established {
Cluster.negotiated(this.#cluster),
);
- // We always declare that advertisements to us must be solicited (MoQ
- // Solicit), and writing the option at all proves the peer implements the
- // extension, whichever value it chose. It also cannot have advertised
- // before reading our SETUP, since our SETUP is what says whether
+ // Unless we declined it, we declare that advertisements to us must be
+ // solicited (MoQ Solicit), and writing the option at all proves the peer
+ // implements the extension, whichever value it chose. It also cannot have
+ // advertised before reading our SETUP, since our SETUP is what says whether
// advertising unasked is allowed. So this is a bug in the peer, and a
- // silent one on both sides if we tolerate it.
+ // silent one on both sides if we tolerate it. Without our declaration an
+ // unasked announce is what we invited.
//
// Draft-14/15 are exempt: they have no inline NAMESPACE, so a
// PUBLISH_NAMESPACE request is also how a peer answers our
// SUBSCRIBE_NAMESPACE there, and the message alone does not say which.
const legacy = this.#session.version === Version.DRAFT_14 || this.#session.version === Version.DRAFT_15;
- if (this.#solicit !== undefined && !legacy) {
+ if (this.#declaredSolicit && this.#solicit !== undefined && !legacy) {
console.error(
`unsolicited publish_namespace from a peer that implements MoQ Solicit: broadcast=${msg.trackNamespace}`,
);
@@ -384,6 +434,12 @@ export class Connection implements Established {
await this.#subscriber.runPublishNamespace(msg, stream);
break;
}
+ case BigInt(AuthMessage.id): {
+ // Only a peer that negotiated MoQ Auth may send one.
+ if (!this.#auth.negotiated) throw new ProtocolViolation("AUTH without MoQ Auth");
+ await this.#auth.serve(stream);
+ break;
+ }
case BigInt(Publish.id): {
const msg = await Publish.decode(stream.reader, this.#session.version);
await this.#subscriber.runPublish(msg, stream);
diff --git a/js/net/src/ietf/error.test.ts b/js/net/src/ietf/error.test.ts
index dbee25afa3..12d415cbcb 100644
--- a/js/net/src/ietf/error.test.ts
+++ b/js/net/src/ietf/error.test.ts
@@ -182,6 +182,7 @@ test("stream reset codes are shared only where the draft agrees", () => {
StreamCode.Evicted,
StreamCode.FrameTooLarge,
StreamCode.GroupTooLarge,
+ StreamCode.Unauthorized,
]) {
expect(sharedStreamCode(code, version)).toBe(false);
}
diff --git a/js/net/src/ietf/index.ts b/js/net/src/ietf/index.ts
index f475cadc0b..269f01b1b9 100644
--- a/js/net/src/ietf/index.ts
+++ b/js/net/src/ietf/index.ts
@@ -1,4 +1,5 @@
export * from "./adapter.ts";
+export * as Auth from "./auth.ts";
export * as Cluster from "./cluster.ts";
export * from "./connection.ts";
export * from "./datagram.ts";
diff --git a/js/net/src/ietf/message.ts b/js/net/src/ietf/message.ts
index a767ddd413..06be4faacd 100644
--- a/js/net/src/ietf/message.ts
+++ b/js/net/src/ietf/message.ts
@@ -1,7 +1,8 @@
import { Reader, Writer } from "../stream.ts";
-// Encodes a message with a u16 (16-bit) size prefix as per draft-14.
-export async function encode(writer: Writer, f: (w: Writer) => Promise) {
+// Encodes a message with a u16 (16-bit) size prefix as per draft-14. A type `id` is written
+// only once the body fits, so an oversized message leaves nothing on the stream.
+export async function encode(writer: Writer, f: (w: Writer) => Promise, id?: number) {
let scratch = new Uint8Array();
const temp = new Writer(
@@ -44,6 +45,8 @@ export async function encode(writer: Writer, f: (w: Writer) => Promise) {
throw new Error(`Message too large: ${scratch.byteLength} bytes (max 65535)`);
}
+ if (id !== undefined) await writer.u53(id);
+
// Write u16 size (2 bytes, big-endian)
await writer.u16(scratch.byteLength);
await writer.write(scratch);
diff --git a/js/net/src/ietf/parameters.ts b/js/net/src/ietf/parameters.ts
index 0ddfc08765..87e0af8d42 100644
--- a/js/net/src/ietf/parameters.ts
+++ b/js/net/src/ietf/parameters.ts
@@ -2,13 +2,14 @@ import { ProtocolViolation } from "../error.ts";
import type { Reader, Writer } from "../stream.ts";
import * as Varint from "../varint.ts";
import * as Filter from "./filter.ts";
+import { AUTHORIZATION_TOKEN, tokenFromRequest } from "./token.ts";
import { type IetfVersion, Version } from "./version.ts";
/// Setup Option key constants (separate namespace from Message Parameters).
export const SetupOption = {
Path: 1n,
MaxRequestId: 2n,
- AuthorizationToken: 3n,
+ AuthorizationToken: AUTHORIZATION_TOKEN,
MaxAuthTokenCacheSize: 4n,
Authority: 5n,
Implementation: 7n,
@@ -20,6 +21,8 @@ export const SetupOption = {
Solicit: 0x40b5an,
/** HIDDEN, from the MoQ Hidden extension. See `hidden.ts`. */
Hidden: 0x40b5cn,
+ /** AUTH, from the MoQ Auth extension. See `auth.ts`. */
+ Auth: 0x40b60n,
} as const;
// Unknown SETUP options may repeat, including GREASE (draft-21 section 9.1).
@@ -219,8 +222,8 @@ const MSG_PARAM_ROUTE_COST = 0x40b58n;
const MSG_PARAM_HIDDEN = 0x40b5en;
// Bytes parameter IDs (odd)
-/// AUTHORIZATION TOKEN. Ignored: the session's grant is what authorizes a request.
-const MSG_PARAM_AUTHORIZATION_TOKEN = 0x03n;
+/// AUTHORIZATION TOKEN. Decoded, but grants nothing: the session's grant is what authorizes a request.
+const MSG_PARAM_AUTHORIZATION_TOKEN = AUTHORIZATION_TOKEN;
const MSG_PARAM_LARGEST_OBJECT = 0x09n;
const MSG_PARAM_SUBSCRIPTION_FILTER = 0x21n;
/// FILL_PARAMETERS, draft-20's request for a backfill.
@@ -252,6 +255,7 @@ function draft20(version: IetfVersion): boolean {
/** True when this draft's definition of `message` includes `id`. */
function allows(message: ControlMessage, version: IetfVersion, id: bigint): boolean {
+ const d14 = version === Version.DRAFT_14;
const d15 = version === Version.DRAFT_15;
const d16 = version === Version.DRAFT_16;
const d17 = version === Version.DRAFT_17;
@@ -292,6 +296,8 @@ function allows(message: ControlMessage, version: IetfVersion, id: bigint): bool
include
);
case "subscribe-ok":
+ // Draft-14 defines only MAX_CACHE_DURATION here.
+ if (d14) return id === MSG_PARAM_MAX_CACHE_DURATION;
return (id === MSG_PARAM_MAX_CACHE_DURATION && d15) || expires || largest || (order && d15);
case "subscribe-update":
case "request-update":
@@ -438,7 +444,9 @@ export class Parameters {
this.#repeated = new Map();
}
- #repeat(id: bigint, value: Uint8Array) {
+ #repeat(id: bigint, value: Uint8Array, version: IetfVersion) {
+ // Every token decodes, so one this endpoint cannot use closes the session.
+ if (id === MSG_PARAM_AUTHORIZATION_TOKEN) tokenFromRequest(value, version);
const values = this.#repeated.get(id) ?? [];
values.push(value);
this.#repeated.set(id, values);
@@ -717,7 +725,9 @@ export class Parameters {
* draft defines for a different message is ignored through draft-16 and closes the
* session from draft-17 on. An id the draft does not define at all closes from
* draft-16 on. Draft-14 and draft-15 ignore anything the message does not list.
- * Omitting `message` keeps the previous decode, which stores every id.
+ * Omitting `message`, as a draft-14 request does, stores every id.
+ *
+ * Only a request may carry an AUTHORIZATION TOKEN, and every one decodes.
*/
static async decode(r: Reader, version: IetfVersion, message?: ControlMessage): Promise {
const count = await r.u53();
@@ -764,7 +774,7 @@ export class Parameters {
const size = await r.u53();
const bytes = await r.read(size);
if (MSG_PARAM_REPEATABLE.includes(id)) {
- params.#repeat(id, bytes);
+ params.#repeat(id, bytes, version);
} else if (id === MSG_PARAM_LARGEST_OBJECT) {
if (params.#locations.has(id)) {
throw new Error(`duplicate message parameter id: ${id.toString()}`);
@@ -788,7 +798,7 @@ export class Parameters {
if (MSG_PARAM_REPEATABLE.includes(id)) {
const size = await r.u53();
- params.#repeat(id, await r.read(size));
+ params.#repeat(id, await r.read(size), version);
continue;
}
diff --git a/js/net/src/ietf/publish.ts b/js/net/src/ietf/publish.ts
index 19b1c94cd6..4f48c79156 100644
--- a/js/net/src/ietf/publish.ts
+++ b/js/net/src/ietf/publish.ts
@@ -216,6 +216,7 @@ export class PublishError {
/** PUBLISH_DONE status codes this implementation distinguishes. Stable across drafts 14 through 22. */
export const PublishDoneStatus = {
INTERNAL_ERROR: 0x0,
+ UNAUTHORIZED: 0x1,
TRACK_ENDED: 0x2,
/** Removed in draft-20, where 0x3 is unassigned. */
SUBSCRIPTION_ENDED: 0x3,
diff --git a/js/net/src/ietf/publisher.test.ts b/js/net/src/ietf/publisher.test.ts
index 80ae59bae2..ed56d21d90 100644
--- a/js/net/src/ietf/publisher.test.ts
+++ b/js/net/src/ietf/publisher.test.ts
@@ -1,5 +1,6 @@
import { expect, jest, mock, spyOn, test } from "bun:test";
import { Signal } from "@moq/signals";
+import type { Grant } from "../auth.ts";
import type { Producer as BroadcastProducer } from "../broadcast.ts";
import * as Epoch from "../epoch.ts";
import { error } from "../error.ts";
@@ -14,6 +15,7 @@ import type { Producer as TrackProducer } from "../track.ts";
import { wireOf } from "../wire.ts";
import { ControlStreamAdapter, NativeSession, type Session } from "./adapter.ts";
import type * as Cluster from "./cluster.ts";
+import { toRequestCode } from "./error.ts";
import { Fetch, FetchHeader } from "./fetch.ts";
import { Frame, Group as GroupMessage } from "./object.ts";
import { PublishDone } from "./publish.ts";
@@ -115,7 +117,13 @@ function publisher(
requiresSolicitation = false,
session,
cluster,
- }: { requiresSolicitation?: boolean; session?: Session; cluster?: Cluster.Hops } = {},
+ grant,
+ }: {
+ requiresSolicitation?: boolean;
+ session?: Session;
+ cluster?: Cluster.Hops;
+ grant?: Signal;
+ } = {},
): { pub: Publisher; origin: OriginProducer } {
const origin = new OriginProducer();
const inner = session ?? new NativeSession(transport, VERSION, true);
@@ -126,6 +134,7 @@ function publisher(
publish: origin.consume(),
requiresSolicitation,
cluster,
+ grant,
}),
origin,
};
@@ -220,6 +229,49 @@ test("FETCH and a non-forwarding SUBSCRIBE get NOT_SUPPORTED", async () => {
}
});
+/** A request's USE_VALUE token decodes but grants nothing yet, so a SUBSCRIBE outside the grant is still refused. */
+test("a request token does not widen the grant", async () => {
+ for (const version of [Version.DRAFT_14, Version.DRAFT_22] as const) {
+ const pair = createMockTransportPair(ALPNS[version]);
+ const session = new NativeSession(pair.server, version, true);
+ const nothing = new Signal({
+ publish: new Path.Patterns([]),
+ subscribe: new Path.Patterns([]),
+ });
+ const { pub, origin } = publisher(pair.server, { session, grant: nothing });
+ publish(origin, Path.from("room"));
+
+ const ascii = (text: string) => [...new TextEncoder().encode(text)];
+ // Draft-14 carries priority, group order, forward, and a Largest Object filter inline.
+ const fields = version === Version.DRAFT_14 ? [0x80, 0x02, 0x01, 0x02] : [];
+ const token = [0x03, 0x00, 0xaa]; // USE_VALUE, type 0
+ const body = [0, 1, 4, ...ascii("room"), 5, ...ascii("video"), ...fields, 1, 0x03, token.length, ...token];
+ const msg = await Subscribe.decode(
+ new Reader(undefined, new Uint8Array([0, body.length, ...body]), version),
+ version,
+ );
+
+ const written: Uint8Array[] = [];
+ const stream = new Stream({
+ readable: new ReadableStream(),
+ writable: new WritableStream({
+ write: (chunk) => {
+ written.push(new Uint8Array(chunk));
+ },
+ }),
+ version,
+ });
+ await pub.runSubscribe(msg, stream);
+ await stream.writer.closed;
+ origin.close();
+
+ // SUBSCRIBE_ERROR on draft-14, REQUEST_ERROR after; the code follows the Length and any Request ID.
+ const wire = written.flatMap((chunk) => Array.from(chunk));
+ expect(wire[0]).toBe(0x05);
+ expect(wire[version === Version.DRAFT_14 ? 4 : 3]).toBe(toRequestCode("unauthorized", "subscribe", version));
+ }
+});
+
// The header is part of the group's lifetime too. If it blocks on flow control, advancing
// the live edge must reset the stream without waiting for that write to finish.
test("a blocked group header is reset when the group expires", async () => {
diff --git a/js/net/src/ietf/publisher.ts b/js/net/src/ietf/publisher.ts
index 78723708fe..f111baef4c 100644
--- a/js/net/src/ietf/publisher.ts
+++ b/js/net/src/ietf/publisher.ts
@@ -1,4 +1,6 @@
import { type Dispose, type Getter, race, Signal } from "@moq/signals";
+import type { Grant } from "../auth.ts";
+import { enforceGrant } from "../auth_session.ts";
import type * as broadcast from "../broadcast.ts";
import { Withdrawal } from "../connection/withdrawal.ts";
import * as DatagramStream from "../datagram_stream.ts";
@@ -254,6 +256,15 @@ export class Publisher {
// back came from us. `undefined` when nothing negotiated it.
#advert?: Cluster.Advert;
+ // Our grant (MoQ Auth): only what it lets us publish is advertised and served, and a
+ // shrink withdraws what it no longer covers. Undefined until the peer answers, which
+ // allows everything.
+ #grant: Getter;
+
+ // Resolves once the tokens this session presented at setup are answered, so nothing is
+ // advertised before the grant it would be checked against.
+ #ready: Promise;
+
/**
* Creates a new Publisher instance.
*
@@ -266,6 +277,8 @@ export class Publisher {
requiresSolicitation,
hidden = false,
cluster,
+ grant = new Signal(undefined),
+ ready = Promise.resolve(),
}: {
/** The WebTransport session, for uni streams. */
quic: WebTransport;
@@ -279,8 +292,14 @@ export class Publisher {
hidden?: boolean;
/** The Hop IDs the SETUP exchange settled (MoQ Cluster). */
cluster?: Cluster.Hops;
+ /** The union of our tokens' grants (MoQ Auth), which bounds what we publish. */
+ grant?: Getter;
+ /** Resolves once the setup tokens are answered (MoQ Auth). */
+ ready?: Promise;
}) {
this.#quic = quic;
+ this.#grant = grant;
+ this.#ready = ready;
this.#session = session;
const origin = publish && wireOf(publish);
this.#advertised = origin?.advertised ?? new Signal(new Map());
@@ -325,6 +344,10 @@ export class Publisher {
if (unsupported) {
refusal = { errorCode: toRequestCode("not_supported", "subscribe", version), reasonPhrase: unsupported };
+ } else if (this.#denied(name)) {
+ // Serve only what our grant lets us publish. Checked before resolving, so a denied
+ // request never reaches the origin.
+ refusal = { errorCode: toRequestCode("unauthorized", "subscribe", version), reasonPhrase: "not granted" };
} else {
try {
broadcast =
@@ -572,13 +595,33 @@ export class Publisher {
})
: Promise.resolve();
+ // Losing the grant ends the subscription, leaving the session alone.
+ let revoke!: () => void;
+ const revoked = new Promise<"revoked">((resolve) => {
+ revoke = () => resolve("revoked");
+ });
+ const disposeGrant = this.#grant.subscribe(() => {
+ if (this.#denied(name)) revoke();
+ });
+ // The grant may have shrunk during setup, before this watcher existed.
+ if (this.#denied(name)) revoke();
+
let publishError: Error | undefined;
+ let unauthorized = false;
let ended = false;
try {
const served = Symbol("served");
- ended = (await race([Promise.all([serving, filling]).then(() => served), requestEnded])) === served;
+ const end = await race([Promise.all([serving, filling]).then(() => served), requestEnded, revoked]);
+ if (end === "revoked") {
+ console.info(`subscription no longer authorized: broadcast=${name} track=${track.name}`);
+ unauthorized = true;
+ unsubscribe();
+ }
+ ended = end === served;
} catch (err: unknown) {
publishError = error(err);
+ } finally {
+ disposeGrant();
}
// PUBLISH_DONE waits until every stream this subscription will open is closed, as
@@ -613,15 +656,17 @@ export class Publisher {
version === Version.DRAFT_14 || version === Version.DRAFT_15 || version === Version.DRAFT_16
? msg.requestId
: undefined,
- statusCode:
- publishError instanceof UpdateFailed
+ statusCode: unauthorized
+ ? PublishDoneStatus.UNAUTHORIZED
+ : publishError instanceof UpdateFailed
? PublishDoneStatus.UPDATE_FAILED
: publishError
? PublishDoneStatus.INTERNAL_ERROR
: PublishDoneStatus.TRACK_ENDED,
streamCount: BigInt(streams.opened),
- reasonPhrase:
- publishError instanceof UpdateFailed
+ reasonPhrase: unauthorized
+ ? "not granted"
+ : publishError instanceof UpdateFailed
? "update failed"
: publishError
? "internal error"
@@ -921,6 +966,22 @@ export class Publisher {
}
}
+ // Whether our grant excludes publishing this broadcast. No grant yet allows it.
+ #denied(broadcast: Path.Valid): boolean {
+ const grant = this.#grant.peek();
+ return grant !== undefined && !grant.publish.matches(broadcast);
+ }
+
+ /**
+ * Close the session when our origin publishes a broadcast our grant does not cover;
+ * see {@link enforceGrant}.
+ *
+ * @internal
+ */
+ async runEnforce(setupAnswered: Promise): Promise {
+ await enforceGrant({ quic: this.#quic, advertised: this.#advertised, grant: this.#grant, setupAnswered });
+ }
+
/**
* Handles an incoming SUBSCRIBE_NAMESPACE on a bidi stream.
*
@@ -973,7 +1034,11 @@ export class Publisher {
const visible = (covered: Path.Valid) => !this.#hidden || msg.hidden || !hiddenBelow(prefix, covered);
const carries = (covered: Path.Valid) =>
visible(covered) &&
- (legacy ? this.#requiresSolicitation || (this.#hidden && hiddenBelow(Path.empty(), covered)) : true);
+ (legacy ? this.#requiresSolicitation || (this.#hidden && hiddenBelow(Path.empty(), covered)) : true) &&
+ !this.#denied(covered);
+
+ // Nothing is advertised before the grant it would be checked against.
+ await this.#ready;
// Inline entries always land, and the receiver treats a repeated NAMESPACE as a
// replacement, so repricing one is just sending it again, a new original
@@ -1011,8 +1076,15 @@ export class Publisher {
// waits for its reply only notifies listeners already registered.
// TODO Make a better helper within Signals.
let dispose!: Dispose;
- const changed = new Promise((resolve) => {
- dispose = this.#advertised.changed(resolve);
+ const changed = new Promise<"changed">((resolve) => {
+ const table = this.#advertised.changed(() => resolve("changed"));
+ // A grant change re-diffs the same way, withdrawing what it no longer covers.
+ // The loop top re-reads the table, so an ended origin still stops it there.
+ const grant = this.#grant.changed(() => resolve("changed"));
+ dispose = () => {
+ table();
+ grant();
+ };
});
const advertised = this.#advertised.peek();
@@ -1092,6 +1164,9 @@ export class Publisher {
let dispose: Dispose | undefined;
try {
+ // Nothing is advertised before the grant it would be checked against.
+ if ((await Promise.race([this.#ready.then(() => "ready" as const), closed])) !== "ready") return;
+
// Keyed by path.
const ns: Namespaces = { active: new Map(), refused: new Map(), offered: new Map() };
let retry = 0;
@@ -1103,8 +1178,15 @@ export class Publisher {
// through it and leave the namespace unadvertised until something unrelated
// changed.
// TODO Make a better helper within Signals.
- const changed = new Promise((resolve) => {
- dispose = this.#advertised.changed(resolve);
+ const changed = new Promise<"changed">((resolve) => {
+ const table = this.#advertised.changed(() => resolve("changed"));
+ // A grant change re-diffs the same way, withdrawing what it no longer covers.
+ // The loop top re-reads the table, so an ended origin still stops it there.
+ const grant = this.#grant.changed(() => resolve("changed"));
+ dispose = () => {
+ table();
+ grant();
+ };
});
const advertised = this.#advertised.peek();
@@ -1115,8 +1197,14 @@ export class Publisher {
const updated = new Map();
for (const [covered, candidates] of advertised) {
- // Only peers that declared MoQ Hidden filter unsolicited discovery.
- if ((this.#hidden && hiddenBelow(Path.empty(), covered)) || candidates.length === 0) continue;
+ // Only peers that declared MoQ Hidden filter unsolicited discovery, and
+ // nothing our grant does not cover reaches the peer at all (MoQ Auth).
+ if (
+ (this.#hidden && hiddenBelow(Path.empty(), covered)) ||
+ this.#denied(covered) ||
+ candidates.length === 0
+ )
+ continue;
updated.set(covered, candidates[0]);
}
diff --git a/js/net/src/ietf/solicit.test.ts b/js/net/src/ietf/solicit.test.ts
index be31565f46..c7937498b3 100644
--- a/js/net/src/ietf/solicit.test.ts
+++ b/js/net/src/ietf/solicit.test.ts
@@ -3,7 +3,7 @@ import { SetupOption, SetupOptions } from "./parameters.ts";
import { solicitFromSetup, solicitIntoSetup } from "./solicit.ts";
/**
- * We declare it on every session, so a peer honoring it never sends us an unsolicited
+ * We declare it by default, so a peer honoring it never sends us an unsolicited
* PUBLISH_NAMESPACE. The literal 1 is the wire contract, not an implementation detail.
*/
test("we declare that advertisements must be solicited", () => {
diff --git a/js/net/src/ietf/solicit.ts b/js/net/src/ietf/solicit.ts
index a01089dd4e..9ee96c3494 100644
--- a/js/net/src/ietf/solicit.ts
+++ b/js/net/src/ietf/solicit.ts
@@ -37,9 +37,9 @@ export function solicitFromSetup(params: SetupOptions): boolean | undefined {
/**
* Declare that advertisements to us must be solicited.
*
- * Unconditional, and true of every session: we send SUBSCRIBE_NAMESPACE for each prefix
- * we want, so there is nothing an unsolicited PUBLISH_NAMESPACE could tell us that we
- * will not have asked for.
+ * Declared unless the session's `extensions` declines it: we send SUBSCRIBE_NAMESPACE for
+ * each prefix we want, so there is nothing an unsolicited PUBLISH_NAMESPACE could tell us
+ * that we will not have asked for.
*
* @internal
*/
diff --git a/js/net/src/ietf/subscribe.ts b/js/net/src/ietf/subscribe.ts
index bc9d341e0c..1eed75c3ea 100644
--- a/js/net/src/ietf/subscribe.ts
+++ b/js/net/src/ietf/subscribe.ts
@@ -300,7 +300,7 @@ export class SubscribeOk {
largest = { groupId, objectId };
}
- properties.maxCacheDuration = (await Parameters.decode(r, version)).maxCacheDuration;
+ properties.maxCacheDuration = (await Parameters.decode(r, version, "subscribe-ok")).maxCacheDuration;
} else {
// v15+: parameters followed by Track Properties (draft-17+). LARGEST_OBJECT is
// required on every draft once the track has content, so rejecting it would tear
diff --git a/js/net/src/ietf/subscriber.ts b/js/net/src/ietf/subscriber.ts
index 76d775fb26..4af30a1b72 100644
--- a/js/net/src/ietf/subscriber.ts
+++ b/js/net/src/ietf/subscriber.ts
@@ -1,5 +1,6 @@
-import { type GetPromise, race, Signal } from "@moq/signals";
+import { type Dispose, type GetPromise, type Getter, race, Signal } from "@moq/signals";
import * as announce from "../announced.ts";
+import type { Grant } from "../auth.ts";
import * as broadcast from "../broadcast.ts";
import type { Drain } from "../connection/goaway.ts";
import { BroadcastCache } from "../consume.ts";
@@ -13,6 +14,7 @@ import {
StreamCode,
Stream as StreamError,
sessionCause,
+ unauthorized,
} from "../error.ts";
import * as netGroup from "../group.ts";
import { Cost, type Route, routesEqual, UNKNOWN_HOP } from "../hop.ts";
@@ -164,6 +166,9 @@ export class Subscriber {
// Settles when the peer sends GOAWAY, repricing this session's routes to the drain cost.
#goaway?: GetPromise;
+ // Our grant (MoQ Auth): a subscription it stops covering is cancelled. Undefined until
+ // the peer answers, which allows everything.
+ #grant?: Getter;
/** Marks this subscriber's deliberate local session close. @internal */
close() {
@@ -182,6 +187,7 @@ export class Subscriber {
hidden = false,
solicit,
goaway,
+ grant,
}: {
/** The session abstraction for bidi streams and request IDs. */
session: Session;
@@ -195,6 +201,8 @@ export class Subscriber {
solicit?: boolean;
/** Settles when the peer sends GOAWAY. */
goaway?: GetPromise;
+ /** The union of our tokens' grants (MoQ Auth), which bounds what we subscribe to. */
+ grant?: Getter;
}) {
this.#session = session;
this.#quic = quic;
@@ -202,6 +210,7 @@ export class Subscriber {
this.#hidden = hidden;
this.#solicit = solicit;
this.#goaway = goaway;
+ this.#grant = grant;
// A draining peer usually stops publishing namespaces, so reprice from the signal
// itself. Waiting for another message would leave the route primary until close.
if (goaway) void goaway.then(() => this.#drainAnnounced());
@@ -228,6 +237,12 @@ export class Subscriber {
}
}
+ // Whether our grant no longer lets us subscribe to `broadcast`. No grant yet allows it.
+ #denied(broadcast: Path.Valid): boolean {
+ const grant = this.#grant?.peek();
+ return grant !== undefined && !grant.subscribe.matches(broadcast);
+ }
+
/**
* Whether an advertisement is ours coming back: its hop path already ran through us, so
* subscribing via it would route us back to ourselves.
@@ -586,6 +601,12 @@ export class Subscriber {
}
async #runSubscribe(broadcast: Path.Valid, request: track.Request) {
+ const refused = unauthorized(broadcast);
+ if (this.#denied(broadcast)) {
+ request.reject(refused);
+ return;
+ }
+
const requestId = await this.#session.nextRequestId();
if (requestId === undefined) {
request.reject(await this.#closedSession());
@@ -696,18 +717,33 @@ export class Subscriber {
return;
}
+ let disposeGrant: Dispose | undefined;
try {
// Which terminal fired decides whether we owe the publisher a cancellation, so
// tag them rather than racing bare promises.
const publisherEnded = Symbol("publisher");
const localEnded = Symbol("local");
+ const revokedEnded = Symbol("revoked");
const idle = Symbol("idle");
+ // Losing the grant ends the subscription, leaving the session alone.
+ let revoke!: () => void;
+ const revoked = new Promise((resolve) => {
+ revoke = () => resolve(revokedEnded);
+ });
+ disposeGrant = this.#grant?.subscribe(() => {
+ if (this.#denied(broadcast)) revoke();
+ });
+ // The grant may have shrunk during setup, before this watcher existed.
+ if (this.#denied(broadcast)) revoke();
+
// Terminal conditions settle at most once (PublishDone, track close = local
- // unsubscribe); race them once so the demand loop doesn't re-subscribe each pass.
+ // unsubscribe, a revoked grant); race them once so the demand loop doesn't
+ // re-subscribe each pass.
const done = race([
this.#runPublishDone(stream, subscription).then(() => publisherEnded),
producer.closed.then(() => localEnded),
+ revoked,
]);
// Serve until a terminal condition fires or the last local subscriber leaves. The unused
@@ -726,7 +762,12 @@ export class Subscriber {
// reopens the window the demand re-check above just closed, and a subscriber that
// returned during it would be closed by this line. The lite subscriber closes
// straight out of its loop for the same reason.
- producer.close();
+ if (terminal === revokedEnded) {
+ console.info(`subscription no longer authorized: broadcast=${broadcast} track=${request.name}`);
+ producer.close(refused);
+ } else {
+ producer.close();
+ }
// The publisher already ended the request, so there is nothing to cancel. Sending
// UNSUBSCRIBE here would name a request it has already torn down.
@@ -742,6 +783,7 @@ export class Subscriber {
`subscribe error: id=${requestId} broadcast=${broadcast} track=${request.name} error=${reason(e)}`,
);
} finally {
+ disposeGrant?.();
// Only the owner tears down the alias metadata: a later subscription may have
// reclaimed the alias and installed its own timescale.
if (this.#aliases.retire(trackAlias, subscription)) this.#timescales.delete(trackAlias);
diff --git a/js/net/src/ietf/token.test.ts b/js/net/src/ietf/token.test.ts
index 5d4b4137d9..991230aefb 100644
--- a/js/net/src/ietf/token.test.ts
+++ b/js/net/src/ietf/token.test.ts
@@ -1,9 +1,9 @@
import { expect, test } from "bun:test";
-import { SessionCode, SessionError } from "../error.ts";
+import { ProtocolViolation, SessionCode } from "../error.ts";
import { Reader, Writer } from "../stream.ts";
import * as Varint from "../varint.ts";
import { SetupOption, SetupOptions } from "./parameters.ts";
-import { TOKEN_OUT_OF_BAND, type Token, tokenFromSetup, tokenIntoSetup } from "./token.ts";
+import { TOKEN_OUT_OF_BAND, type Token, tokenFromRequest, tokenFromSetup, tokenIntoSetup } from "./token.ts";
import { type IetfVersion, Version } from "./version.ts";
const VERSIONS: IetfVersion[] = [
@@ -54,16 +54,21 @@ async function received(params: SetupOptions, version: IetfVersion): Promise varint(field, version)), value];
- const raw = new Uint8Array(parts.reduce((total, part) => total + part.length, 0));
+ const out = new Uint8Array(parts.reduce((total, part) => total + part.length, 0));
let offset = 0;
for (const part of parts) {
- raw.set(part, offset);
+ out.set(part, offset);
offset += part.length;
}
+ return out;
+}
+
+/** The Token structure as a SETUP option. */
+function structure(version: IetfVersion, fields: bigint[], value: Uint8Array = new Uint8Array()): SetupOptions {
const params = new SetupOptions();
- params.setBytes(SetupOption.AuthorizationToken, raw);
+ params.setBytes(SetupOption.AuthorizationToken, raw(version, fields, value));
return params;
}
@@ -71,7 +76,7 @@ function sessionCode(fn: () => unknown): SessionCode | undefined {
try {
fn();
} catch (err) {
- if (err instanceof SessionError) return err.code;
+ if (err instanceof ProtocolViolation) return err.code;
throw err;
}
return undefined;
@@ -119,11 +124,12 @@ test("REGISTER is a value", () => {
}
});
-test("an alias reference is a protocol violation", () => {
+/** A cache size of 0 means no alias was ever registered. */
+test("an alias reference is an unknown alias", () => {
for (const version of VERSIONS) {
for (const aliasType of [0x0n, 0x2n]) {
const params = structure(version, [aliasType, 7n]);
- expect(sessionCode(() => tokenFromSetup(params, version))).toBe(SessionCode.ProtocolViolation);
+ expect(sessionCode(() => tokenFromSetup(params, version))).toBe(SessionCode.UnknownAuthTokenAlias);
}
}
});
@@ -153,3 +159,35 @@ test("two tokens are refused", async () => {
);
}
});
+
+test("a request token by value decodes", () => {
+ for (const version of VERSIONS) {
+ expect(tokenFromRequest(raw(version, [0x3n, TOKEN.kind], TOKEN.value), version)).toEqual(TOKEN);
+ }
+});
+
+/** Only SETUP falls back to a value: on a request, a registration overflows the cache. */
+test("a request registration overflows the cache", () => {
+ for (const version of VERSIONS) {
+ const token = raw(version, [0x1n, 7n, TOKEN.kind], TOKEN.value);
+ expect(sessionCode(() => tokenFromRequest(token, version))).toBe(SessionCode.AuthTokenCacheOverflow);
+ }
+});
+
+test("a request alias reference is an unknown alias", () => {
+ for (const version of VERSIONS) {
+ for (const aliasType of [0x0n, 0x2n]) {
+ const token = raw(version, [aliasType, 7n]);
+ expect(sessionCode(() => tokenFromRequest(token, version))).toBe(SessionCode.UnknownAuthTokenAlias);
+ }
+ }
+});
+
+test("an undecodable request token is a formatting error", () => {
+ for (const version of VERSIONS) {
+ for (const fields of [[], [0x3n], [0x4n, 0n]]) {
+ const token = raw(version, fields);
+ expect(sessionCode(() => tokenFromRequest(token, version))).toBe(SessionCode.KeyValueFormatting);
+ }
+ }
+});
diff --git a/js/net/src/ietf/token.ts b/js/net/src/ietf/token.ts
index 818466696f..17327bbbcc 100644
--- a/js/net/src/ietf/token.ts
+++ b/js/net/src/ietf/token.ts
@@ -1,10 +1,11 @@
-import { SessionCode, SessionError } from "../error.ts";
+import { ProtocolViolation, SessionCode } from "../error.ts";
import * as Varint from "../varint.ts";
-import { SetupOption, type SetupOptions } from "./parameters.ts";
+import type { SetupOptions } from "./parameters.ts";
import { type IetfVersion, Version } from "./version.ts";
/**
- * The `AUTHORIZATION TOKEN` Setup Option (draft-ietf-moq-transport-21 section 9.1.4).
+ * The `AUTHORIZATION TOKEN` Setup Option (draft-ietf-moq-transport-21 section 9.1.4) and
+ * Message Parameter.
*
* The value is the Token structure of section 8.9: an Alias Type, then fields that type
* selects. We advertise no `MAX_AUTH_TOKEN_CACHE_SIZE`, so its default of 0 means no alias
@@ -16,6 +17,13 @@ import { type IetfVersion, Version } from "./version.ts";
* @internal
*/
+/**
+ * The `AUTHORIZATION TOKEN` key, the same as a Setup Option and as a Message Parameter.
+ *
+ * @internal
+ */
+export const AUTHORIZATION_TOKEN = 0x03n;
+
/** Retire a registered alias. */
const DELETE = 0x0n;
/** Register an alias for this type and value, then use them. */
@@ -26,7 +34,7 @@ const USE_ALIAS = 0x2n;
const USE_VALUE = 0x3n;
/**
- * A credential presented in a SETUP's `AUTHORIZATION TOKEN` option.
+ * A credential presented in an `AUTHORIZATION TOKEN` option or parameter.
*
* @internal
*/
@@ -62,17 +70,37 @@ function leadingOnes(version: IetfVersion): boolean {
* A second token is already refused as a duplicate option by {@link SetupOptions}: one
* credential per connection.
*
- * @throws {SessionError} `ProtocolViolation` for an alias reference, which nothing before
- * SETUP could have registered, or `KeyValueFormatting` for a structure that cannot be decoded.
+ * @throws {ProtocolViolation} carrying the session code to close with.
* @internal
*/
export function tokenFromSetup(params: SetupOptions, version: IetfVersion): Token | undefined {
- const raw = params.getBytes(SetupOption.AuthorizationToken);
+ const raw = params.getBytes(AUTHORIZATION_TOKEN);
if (raw === undefined) return undefined;
+ return decode(raw, version, true);
+}
+/**
+ * Decode an `AUTHORIZATION TOKEN` message parameter on a request, by the SETUP option's rules
+ * except that a registration overflows the cache instead of falling back to a value.
+ *
+ * @throws {ProtocolViolation} carrying the session code to close with.
+ * @internal
+ */
+export function tokenFromRequest(raw: Uint8Array, version: IetfVersion): Token {
+ return decode(raw, version, false);
+}
+
+/**
+ * Decode a Token structure, refusing what this endpoint cannot accept.
+ *
+ * @throws {ProtocolViolation} `AuthTokenCacheOverflow` for a registration outside SETUP,
+ * `UnknownAuthTokenAlias` for an alias reference, or `KeyValueFormatting` for a structure that
+ * cannot be decoded.
+ */
+function decode(raw: Uint8Array, version: IetfVersion, setup: boolean): Token {
// Section 8.9: a structure that cannot be decoded closes with KEY_VALUE_FORMATTING_ERROR.
const malformed = (cause?: unknown) =>
- new SessionError(SessionCode.KeyValueFormatting, { cause, reason: "malformed AUTHORIZATION TOKEN" });
+ new ProtocolViolation("malformed AUTHORIZATION TOKEN", { cause, code: SessionCode.KeyValueFormatting });
const unvarint = (buf: Uint8Array): [bigint, Uint8Array] => {
try {
return leadingOnes(version) ? Varint.decodeLeadingOnes(buf) : Varint.decodeBigInt(buf);
@@ -85,14 +113,25 @@ export function tokenFromSetup(params: SetupOptions, version: IetfVersion): Toke
switch (aliasType) {
case USE_VALUE:
break;
- // With no cache, section 9.1.4 treats a registration as a value; the alias is unused.
case REGISTER:
+ // Section 8.9: a registration past the cache size of 0 closes the session.
+ if (!setup) {
+ throw new ProtocolViolation("AUTHORIZATION TOKEN registration", {
+ code: SessionCode.AuthTokenCacheOverflow,
+ });
+ }
+ // With no cache, section 9.1.4 treats a registration in SETUP as a value; the alias
+ // is unused.
[, rest] = unvarint(rest);
break;
- // Section 9.1.4: nothing can have been registered before SETUP.
+ // Section 9.1.3: a cache size of 0 prohibits aliases, so none was ever registered.
+ // Section 8.9 rejects the message, but 0x17 is only a session code (section 12.2), so
+ // the session closes; doc/concept/standard.md records the deviation.
case DELETE:
case USE_ALIAS:
- throw new SessionError(SessionCode.ProtocolViolation, { reason: "AUTHORIZATION TOKEN alias in SETUP" });
+ throw new ProtocolViolation("unknown AUTHORIZATION TOKEN alias", {
+ code: SessionCode.UnknownAuthTokenAlias,
+ });
default:
throw malformed();
}
@@ -115,5 +154,5 @@ export function tokenIntoSetup(params: SetupOptions, token: Token, version: Ietf
out.set(aliasType, 0);
out.set(kind, aliasType.length);
out.set(token.value, aliasType.length + kind.length);
- params.setBytes(SetupOption.AuthorizationToken, out);
+ params.setBytes(AUTHORIZATION_TOKEN, out);
}
diff --git a/js/net/src/index.ts b/js/net/src/index.ts
index 3d516a6071..380378b9c4 100644
--- a/js/net/src/index.ts
+++ b/js/net/src/index.ts
@@ -9,6 +9,8 @@
export * as Signals from "@moq/signals";
/** Broadcast announcement streams. */
export * as Announce from "./announce.ts";
+/** In-band authorization: tokens presented to the peer and the grants they earn. */
+export * as Auth from "./auth.ts";
/** Send-side bandwidth estimates split among the tracks sharing a connection. */
export * as Bandwidth from "./bandwidth_api.ts";
/** Broadcast role handles. */
diff --git a/js/net/src/lite/announce.ts b/js/net/src/lite/announce.ts
index c0e818d5b4..5932b45164 100644
--- a/js/net/src/lite/announce.ts
+++ b/js/net/src/lite/announce.ts
@@ -488,7 +488,7 @@ export class AnnounceInit {
async encode(w: Writer, version: Version): Promise {
AnnounceInit.#guard(version);
- return Message.encode(w, this.#encode.bind(this), AnnounceInit.MAX_SIZE);
+ return Message.encode(w, this.#encode.bind(this), { max: AnnounceInit.MAX_SIZE });
}
static async decode(r: Reader, version: Version): Promise {
diff --git a/js/net/src/lite/auth.test.ts b/js/net/src/lite/auth.test.ts
new file mode 100644
index 0000000000..2f7dd3181d
--- /dev/null
+++ b/js/net/src/lite/auth.test.ts
@@ -0,0 +1,120 @@
+import { expect, test } from "bun:test";
+import { SessionCode } from "../error.ts";
+import * as Path from "../path.ts";
+import { Reader, Writer } from "../stream.ts";
+import { AuthError, AuthMessage, AuthOk, decodeAuthReplyMaybe } from "./auth.ts";
+import * as Lite from "./index.ts";
+import * as Message from "./message.ts";
+
+function patterns(...texts: string[]): Path.Patterns {
+ return new Path.Patterns(texts.map((text) => Path.Pattern.parse(text)));
+}
+
+/** Round-trip bytes through a writer and back out of a reader. */
+async function roundTrip(write: (w: Writer) => Promise): Promise {
+ const chunks: Uint8Array[] = [];
+ const writer = new Writer(
+ new WritableStream({
+ write(chunk) {
+ chunks.push(new Uint8Array(chunk));
+ },
+ }),
+ Lite.Version.DRAFT_07,
+ );
+ await write(writer);
+ writer.close();
+ await writer.closed;
+ const size = chunks.reduce((n, c) => n + c.byteLength, 0);
+ const buf = new Uint8Array(size);
+ let offset = 0;
+ for (const chunk of chunks) {
+ buf.set(chunk, offset);
+ offset += chunk.byteLength;
+ }
+ return new Reader(undefined, buf, Lite.Version.DRAFT_07);
+}
+
+test("AUTH round-trips its token", async () => {
+ const r = await roundTrip((w) => new AuthMessage(new TextEncoder().encode("jwt")).encode(w, Lite.Version.DRAFT_07));
+ const msg = await AuthMessage.decode(r, Lite.Version.DRAFT_07);
+ expect(new TextDecoder().decode(msg.token)).toBe("jwt");
+});
+
+test("AUTH_OK and AUTH_ERROR round-trip behind their type", async () => {
+ const ok = new AuthOk(patterns("**"), patterns(), 60_000);
+ const err = new AuthError(SessionCode.Unauthorized, "expired");
+ const r = await roundTrip(async (w) => {
+ await ok.encode(w, Lite.Version.DRAFT_07);
+ await err.encode(w, Lite.Version.DRAFT_07);
+ });
+ const first = await decodeAuthReplyMaybe(r, Lite.Version.DRAFT_07);
+ expect(first).toBeInstanceOf(AuthOk);
+ if (!(first instanceof AuthOk)) throw new Error("unreachable");
+ // `**` grants everything; the empty list grants nothing.
+ expect(first.publish.equals(new Path.Patterns([Path.Pattern.all()]))).toBe(true);
+ expect(first.subscribe.size).toBe(0);
+ expect(first.expires).toBe(60_000);
+
+ const second = await decodeAuthReplyMaybe(r, Lite.Version.DRAFT_07);
+ expect(second).toEqual(err);
+ expect(await decodeAuthReplyMaybe(r, Lite.Version.DRAFT_07)).toBeUndefined();
+});
+
+test("literal and wildcard grants travel exactly, never widened", async () => {
+ const ok = new AuthOk(patterns("room/alice", "room/*/cam", "**/demo.hang"), patterns("", "lobby/**"));
+ const r = await roundTrip((w) => ok.encode(w, Lite.Version.DRAFT_07));
+ const got = await decodeAuthReplyMaybe(r, Lite.Version.DRAFT_07);
+ if (!(got instanceof AuthOk)) throw new Error("expected AUTH_OK");
+ expect(got.publish.equals(ok.publish)).toBe(true);
+ expect(got.subscribe.equals(ok.subscribe)).toBe(true);
+});
+
+// The same bytes as `auth_ok_golden` in `rs/moq-net/src/lite/auth.rs`.
+test("AUTH_OK matches the Rust encoding", async () => {
+ const ok = new AuthOk(patterns("room/*/cam", "**/b.hang"), patterns(""), 1000);
+ const r = await roundTrip((w) => ok.encode(w, Lite.Version.DRAFT_07));
+ const text = (s: string) => [s.length, ...new TextEncoder().encode(s)];
+ expect([...(await r.readAll())]).toEqual([
+ 0x00, // AUTH_OK
+ 0x1a, // length
+ 0x02, // publish count, in canonical order
+ ...text("**/b.hang"),
+ ...text("room/*/cam"),
+ 0x01, // subscribe count
+ 0x00, // the empty pattern: the root alone
+ 0x83,
+ 0xe8, // expires: 1000ms, as a leading-ones varint
+ ]);
+});
+
+// Mirrors `oversized_message_is_refused` in `rs/moq-net/src/lite/auth.rs`: the peer would
+// refuse it, so the acceptor withholds it, type included, and resets the stream instead.
+test("a reply larger than the peer reads writes nothing", async () => {
+ const body = new Uint8Array(Message.MAX_MESSAGE_SIZE + 1);
+ const r = await roundTrip(async (w) => {
+ await expect(Message.encode(w, (w) => w.write(body), { id: AuthOk.id })).rejects.toThrow("message too large");
+ });
+ expect((await r.readAll()).byteLength).toBe(0);
+});
+
+test("only valid, canonical patterns decode", async () => {
+ for (const text of ["*/**", "/room", "room/", "room//a", "a*b*c", "**/**", "a**"]) {
+ const r = await roundTrip(async (w) => {
+ const body = new TextEncoder().encode(text);
+ await w.u53(0); // AUTH_OK
+ await w.u53(1 + 1 + body.byteLength + 1 + 1);
+ await w.u53(1);
+ await w.string(text);
+ await w.u53(0);
+ await w.u53(0);
+ });
+ await expect(decodeAuthReplyMaybe(r, Lite.Version.DRAFT_07)).rejects.toThrow();
+ }
+});
+
+test.each([
+ ["lite-05", Lite.Version.DRAFT_05],
+ ["lite-06", Lite.Version.DRAFT_06],
+])("%s carries no AUTH", async (_, version) => {
+ await expect(roundTrip((w) => new AuthMessage(new Uint8Array()).encode(w, version))).rejects.toThrow();
+});
diff --git a/js/net/src/lite/auth.ts b/js/net/src/lite/auth.ts
new file mode 100644
index 0000000000..47b7d99cf6
--- /dev/null
+++ b/js/net/src/lite/auth.ts
@@ -0,0 +1,210 @@
+import { Unsupported } from "../auth.ts";
+import type { AuthWire, WireGrant, WireReply } from "../auth_session.ts";
+import { type SessionCode, SessionError } from "../error.ts";
+import * as Path from "../path.ts";
+import { type Reader, Stream, type Writer } from "../stream.ts";
+import * as Message from "./message.ts";
+import { StreamId } from "./stream.ts";
+import { hasAuth, type Version } from "./version.ts";
+
+function guardAuth(version: Version) {
+ if (!hasAuth(version)) throw new Error("auth not supported for this version");
+}
+
+/** Longest AUTH_ERROR reason, in bytes, matching the Rust decoder. */
+const MAX_REASON = 8192;
+
+/** The first message on an Auth Stream: the token the opener presents. Lite07+. */
+export class AuthMessage {
+ token: Uint8Array;
+
+ constructor(token: Uint8Array) {
+ this.token = token;
+ }
+
+ async #encode(w: Writer) {
+ await w.u53(this.token.byteLength);
+ if (this.token.byteLength > 0) await w.write(this.token);
+ }
+
+ static async #decode(r: Reader): Promise {
+ const size = await r.u53();
+ return new AuthMessage(await r.read(size));
+ }
+
+ async encode(w: Writer, version: Version): Promise {
+ guardAuth(version);
+ return Message.encode(w, this.#encode.bind(this));
+ }
+
+ static async decode(r: Reader, version: Version): Promise {
+ guardAuth(version);
+ return Message.decode(r, AuthMessage.#decode);
+ }
+}
+
+// Each pattern travels as its canonical text.
+async function encodePatterns(w: Writer, patterns: Path.Patterns) {
+ await w.u53(patterns.size);
+ for (const pattern of patterns) await w.string(pattern.text);
+}
+
+async function decodePatterns(r: Reader): Promise {
+ const count = await r.u53();
+ const patterns = new Path.Patterns();
+ for (let i = 0; i < count; i++) {
+ const text = await r.string();
+ const pattern = Path.Pattern.parse(text);
+ // Only the canonical spelling is valid, so each pattern has one encoding.
+ if (pattern.text !== text) throw new Error(`non-canonical pattern: ${text}`);
+ patterns.insert(pattern);
+ }
+ return patterns;
+}
+
+/** The grant a token earns, as the acceptor writes it. Encodes its own type. */
+export class AuthOk {
+ static id = 0;
+
+ publish: Path.Patterns;
+ subscribe: Path.Patterns;
+ /** Milliseconds until the grant lapses, or undefined for never. */
+ expires?: number;
+
+ constructor(publish: Path.Patterns, subscribe: Path.Patterns, expires?: number) {
+ this.publish = publish;
+ this.subscribe = subscribe;
+ this.expires = expires;
+ }
+
+ async #encode(w: Writer) {
+ await encodePatterns(w, this.publish);
+ await encodePatterns(w, this.subscribe);
+ // 0 means never, so a lapsed grant rounds up to the smallest real expiry.
+ const expires =
+ this.expires === undefined ? 0 : Math.min(Math.max(Math.ceil(this.expires), 1), Number.MAX_SAFE_INTEGER);
+ await w.u53(expires);
+ }
+
+ static async #decode(r: Reader): Promise {
+ const publish = await decodePatterns(r);
+ const subscribe = await decodePatterns(r);
+ const expires = await r.u53();
+ return new AuthOk(publish, subscribe, expires === 0 ? undefined : expires);
+ }
+
+ async encode(w: Writer, version: Version): Promise {
+ guardAuth(version);
+ return Message.encode(w, this.#encode.bind(this), { id: AuthOk.id });
+ }
+
+ static async decode(r: Reader, version: Version): Promise {
+ guardAuth(version);
+ return Message.decode(r, AuthOk.#decode);
+ }
+}
+
+/** The acceptor refusing a token, or revoking it after an AUTH_OK. Encodes its own type. */
+export class AuthError {
+ static id = 1;
+
+ /** A code from the session error registry. */
+ code: number;
+ reason: string;
+
+ constructor(code: number, reason: string) {
+ this.code = code;
+ this.reason = reason;
+ }
+
+ async #encode(w: Writer) {
+ if (new TextEncoder().encode(this.reason).byteLength > MAX_REASON) {
+ throw new Error("AUTH_ERROR reason exceeds 8,192 bytes");
+ }
+ await w.u53(this.code);
+ await w.string(this.reason);
+ }
+
+ static async #decode(r: Reader): Promise {
+ const code = await r.u53();
+ const reason = await r.string();
+ if (new TextEncoder().encode(reason).byteLength > MAX_REASON) {
+ throw new Error("AUTH_ERROR reason exceeds 8,192 bytes");
+ }
+ return new AuthError(code, reason);
+ }
+
+ async encode(w: Writer, version: Version): Promise {
+ guardAuth(version);
+ return Message.encode(w, this.#encode.bind(this), { id: AuthError.id });
+ }
+
+ static async decode(r: Reader, version: Version): Promise {
+ guardAuth(version);
+ return Message.decode(r, AuthError.#decode);
+ }
+}
+
+/** A message the acceptor writes on the Auth Stream. */
+export type AuthReply = AuthOk | AuthError;
+
+/** Decode the next AUTH_OK or AUTH_ERROR, or undefined once the stream ends. */
+export async function decodeAuthReplyMaybe(r: Reader, version: Version): Promise {
+ guardAuth(version);
+ if (await r.done()) return undefined;
+ const typ = await r.u53();
+ switch (typ) {
+ case AuthOk.id:
+ return AuthOk.decode(r, version);
+ case AuthError.id:
+ return AuthError.decode(r, version);
+ default:
+ throw new Error(`unknown auth reply type: ${typ}`);
+ }
+}
+
+/** The moq-lite binding of the token lifecycle: one Auth Stream per token. @internal */
+export class LiteAuthWire implements AuthWire {
+ #quic: WebTransport;
+ #version: Version;
+
+ constructor(quic: WebTransport, version: Version) {
+ this.#quic = quic;
+ this.#version = version;
+ }
+
+ async present(token: Uint8Array): Promise {
+ const stream = await Stream.open(this.#quic, { version: this.#version });
+ await stream.writer.u53(StreamId.Auth);
+ await new AuthMessage(token).encode(stream.writer, this.#version);
+ return stream;
+ }
+
+ async read(stream: Stream): Promise {
+ const reply = await decodeAuthReplyMaybe(stream.reader, this.#version);
+ if (!reply) return undefined;
+ if (reply instanceof AuthOk) {
+ return { grant: { publish: reply.publish, subscribe: reply.subscribe, expires: reply.expires } };
+ }
+ return { refused: new SessionError(reply.code as SessionCode, { reason: reply.reason }) };
+ }
+
+ /** The stream type is already consumed by the dispatcher. */
+ async accept(stream: Stream): Promise {
+ const msg = await AuthMessage.decode(stream.reader, this.#version);
+ return msg.token;
+ }
+
+ async grant(stream: Stream, grant: WireGrant): Promise {
+ await new AuthOk(grant.publish, grant.subscribe, grant.expires).encode(stream.writer, this.#version);
+ }
+
+ async refuse(stream: Stream, code: SessionCode, reason: string): Promise {
+ await new AuthError(code, reason).encode(stream.writer, this.#version);
+ }
+
+ /** Resetting reads as unsupported to the presenter, the same as a peer that predates AUTH. */
+ async unsupported(stream: Stream, reason: string): Promise {
+ stream.writer.reset(new Unsupported(reason));
+ }
+}
diff --git a/js/net/src/lite/connection.ts b/js/net/src/lite/connection.ts
index bcd6e39cc5..78891d1bf3 100644
--- a/js/net/src/lite/connection.ts
+++ b/js/net/src/lite/connection.ts
@@ -1,17 +1,20 @@
import { type Getter, Once, Signal } from "@moq/signals";
import type * as announce from "../announced.ts";
+import type * as Auth from "../auth.ts";
+import { AuthSession } from "../auth_session.ts";
import type { Established } from "../connection/established.ts";
import type { Drain } from "../connection/goaway.ts";
import { type Probe, type Stats, transportStats } from "../connection/stats.ts";
import { type Transport, transportOf } from "../connection/transport.ts";
-import { closeError, error, fromClose, ProtocolViolation, SessionCode, StreamCode, sessionCause } from "../error.ts";
+import { closeError, error, fromClose, ProtocolViolation, StreamCode, sessionCause } from "../error.ts";
import { type Hop, randomHop } from "../hop.ts";
import type { Consumer as OriginConsumer } from "../origin.ts";
-import type * as Path from "../path.ts";
+import * as Path from "../path.ts";
import { type Reader, Readers, Stream, Writer } from "../stream.ts";
import { withTimeout } from "../util/timeout.ts";
import { registerWire } from "../wire.ts";
import { AnnounceRequest } from "./announce.ts";
+import { LiteAuthWire } from "./auth.ts";
import { Fetch } from "./fetch.ts";
import { Goaway } from "./goaway.ts";
import { Group } from "./group.ts";
@@ -22,7 +25,7 @@ import { DataType, StreamId } from "./stream.ts";
import { Subscribe } from "./subscribe.ts";
import { Subscriber } from "./subscriber.ts";
import { Track as TrackMessage } from "./track.ts";
-import { hasDatagrams, hasProbeRtt, hasSetupStream, type Version, versionName } from "./version.ts";
+import { hasAuth, hasDatagrams, hasProbeRtt, hasSetupStream, type Version, versionName } from "./version.ts";
/**
* Constructor options for {@link Connection}.
@@ -42,6 +45,12 @@ export interface ConnectionProps {
discovery?: boolean;
/** The origin whose broadcasts are served to the peer. Omit to publish nothing. */
publish?: OriginConsumer;
+ /**
+ * Whether this side dialed. Only the dialing side aborts on a publication its grant does
+ * not cover: a server publishes whatever the peer may read, not what it intends to push.
+ * Defaults to true.
+ */
+ client?: boolean;
}
/**
@@ -81,6 +90,9 @@ export class Connection implements Established {
/** The peer's PROBE estimates; see {@link Established.probe}. */
readonly probe: Getter;
+ // Our tokens and grants, and the answers to the peer's; see {@link Established.auth}.
+ #auth: AuthSession;
+
/** Random per-connection Hop ID. Shared by Publisher (for outbound hop
* chains) and Subscriber (available for optional self-filtering on announces). */
readonly hop: Hop;
@@ -98,6 +110,8 @@ export class Connection implements Established {
// Written by the Subscriber as PROBE messages arrive.
#probe = new Signal({});
+ // Whether we dialed; see {@link ConnectionProps.client}.
+ #client: boolean;
// The peer's GOAWAY. Lite carries no deadline, so only the URI is set.
#goaway = new Once();
@@ -119,7 +133,7 @@ export class Connection implements Established {
*
* @internal
*/
- constructor({ url, quic, version, session, discovery = true, publish }: ConnectionProps) {
+ constructor({ url, quic, version, session, discovery = true, publish, client = true }: ConnectionProps) {
this.url = url;
this.#quic = quic;
this.#session = session;
@@ -136,7 +150,19 @@ export class Connection implements Established {
this.probe = this.#probe;
this.hop = randomHop();
- this.#publisher = new Publisher(this.#quic, this.#version, this.hop, publish);
+ // What the peer's connection credential earns by default: publishing anything to us,
+ // since we consume on demand, and subscribing to whatever we publish.
+ this.#auth = new AuthSession({
+ wire: hasAuth(version) ? new LiteAuthWire(quic, version) : undefined,
+ peerGrant: {
+ publish: new Path.Patterns([Path.Pattern.all()]),
+ subscribe: new Path.Patterns(publish ? [Path.Pattern.all()] : []),
+ },
+ });
+ this.#publisher = new Publisher(this.#quic, this.#version, this.hop, publish, {
+ grant: this.#auth.grant,
+ ready: this.#auth.setupAnswered(),
+ });
this.#subscriber = new Subscriber(
this.#quic,
this.#version,
@@ -144,12 +170,19 @@ export class Connection implements Established {
this.#probe,
this.#peerSetup,
this.#goaway,
+ this.#auth.grant,
);
+ this.#client = client;
registerWire(this, { consume: (path) => this.#subscriber.consume(path), goaway: this.#goaway });
void this.#run();
}
+ /** Our tokens and grants; see {@link Established.auth}. */
+ get auth(): Auth.Auth {
+ return this.#auth;
+ }
+
/**
* Withdraw announcements and finish the requests this session serves, waiting up to one
* second for delivery before closing.
@@ -167,6 +200,7 @@ export class Connection implements Established {
/** End the session immediately without waiting for delivery. */
abort(): void {
+ this.#auth.close();
this.#publisher.close();
this.#subscriber.close();
@@ -187,6 +221,12 @@ export class Connection implements Established {
tasks.push(this.#subscriber.runProbe());
+ // Fail loud on a publication our grant never covers, once the peer has answered the
+ // credential we presented at setup.
+ if (this.#client && hasAuth(this.#version)) {
+ tasks.push(this.#publisher.runEnforce(this.#auth.setupAnswered()));
+ }
+
// Route incoming QUIC datagrams into their subscriptions (lite-05+; runDatagrams
// no-ops on a transport that doesn't carry them).
if (hasDatagrams(this.#version)) {
@@ -299,6 +339,8 @@ export class Connection implements Established {
);
} else if (typ === StreamId.Probe) {
await this.#publisher.runProbe(stream);
+ } else if (typ === StreamId.Auth) {
+ await this.#auth.serve(stream);
} else if (typ === StreamId.Goaway) {
const msg = await Goaway.decode(stream.reader, this.#version);
// A peer sends at most one; a second is a protocol violation.
@@ -323,7 +365,7 @@ export class Connection implements Established {
.catch((err: unknown) => {
stream.stop(err);
if (err instanceof ProtocolViolation) {
- this.#quic.close({ closeCode: SessionCode.ProtocolViolation, reason: err.message });
+ this.#quic.close({ closeCode: err.code, reason: err.message });
}
});
}
diff --git a/js/net/src/lite/message.ts b/js/net/src/lite/message.ts
index 885b7d5f7b..e0c22a96e9 100644
--- a/js/net/src/lite/message.ts
+++ b/js/net/src/lite/message.ts
@@ -4,10 +4,15 @@ import { Reader, Writer } from "../stream.ts";
* The largest message body either side accepts, matching the Rust implementation: the
* same ceiling as SETUP, checked at the length prefix before the body is buffered.
*/
-const MAX_SIZE = 0xffff;
+export const MAX_MESSAGE_SIZE = 0xffff;
-// Encodes a message with a varint size prefix, refusing a body the peer would reject.
-export async function encode(writer: Writer, f: (w: Writer) => Promise, max = MAX_SIZE) {
+// Encodes a message with a varint size prefix, refusing a body the peer would reject. A type
+// `id` is written only once the body fits, so a refused message leaves nothing on the stream.
+export async function encode(
+ writer: Writer,
+ f: (w: Writer) => Promise,
+ { id, max = MAX_MESSAGE_SIZE }: { id?: number; max?: number } = {},
+) {
let scratch = new Uint8Array();
const temp = new Writer(
@@ -45,6 +50,7 @@ export async function encode(writer: Writer, f: (w: Writer) => Promise, ma
throw new Error(`message too large: ${scratch.byteLength} bytes (max ${max})`);
}
+ if (id !== undefined) await writer.u53(id);
await writer.u53(scratch.byteLength);
if (scratch.byteLength > 0) {
await writer.write(scratch);
@@ -52,7 +58,7 @@ export async function encode(writer: Writer, f: (w: Writer) => Promise, ma
}
// Reads a message with a varint size prefix, refusing an oversized body before reading it.
-export async function decode(reader: Reader, f: (r: Reader) => Promise, max = MAX_SIZE): Promise {
+export async function decode(reader: Reader, f: (r: Reader) => Promise, max = MAX_MESSAGE_SIZE): Promise {
const size = await reader.u53();
if (size > max) {
throw new Error(`message too large: ${size} bytes (max ${max})`);
diff --git a/js/net/src/lite/publisher.test.ts b/js/net/src/lite/publisher.test.ts
index e6643f8803..24612b667c 100644
--- a/js/net/src/lite/publisher.test.ts
+++ b/js/net/src/lite/publisher.test.ts
@@ -1,5 +1,8 @@
import { expect, mock, spyOn, test } from "bun:test";
import { Signal } from "@moq/signals";
+import type { Grant } from "../auth.ts";
+import { Producer as BroadcastProducer } from "../broadcast.ts";
+import { StreamCode, toStreamCode } from "../error.ts";
import { Producer as GroupProducer } from "../group.ts";
import { randomHop } from "../hop.ts";
import { hooks } from "../internal.ts";
@@ -15,6 +18,7 @@ import { sendOrder } from "./priority.ts";
import { Probe as ProbeMessage } from "./probe.ts";
import { Publisher } from "./publisher.ts";
import { decodeSubscribeResponse, Subscribe, type SubscribeEnd, SubscribeUpdate } from "./subscribe.ts";
+import { Track as TrackMessage } from "./track.ts";
import { ALPN_05, ALPN_06, ALPN_07_WIP, Version } from "./version.ts";
function publish(origin: OriginProducer, path: Path.Valid) {
@@ -1643,6 +1647,290 @@ test("lite draft-05: a group that goes stale while its stream opens writes nothi
}
});
+function grantOf(publish: string): Grant {
+ return { publish: new Path.Patterns([Path.Pattern.subtree(publish)]), subscribe: new Path.Patterns([]) };
+}
+
+// The resets carrying our UNAUTHORIZED code, by message.
+function unauthorizedResets(resets: { mock: { calls: unknown[][] } }): string[] {
+ return resets.mock.calls
+ .map(([reason]) => reason as Error)
+ .filter((reason) => toStreamCode(reason) === StreamCode.Unauthorized)
+ .map((reason) => reason.message);
+}
+
+// Nothing is announced before the grant it would be checked against: an ANNOUNCE_REQUEST that
+// lands before the setup token is answered waits, then leaves out what the grant excludes.
+test("lite draft-06: announces wait for the setup grant", async () => {
+ const pair = createMockTransportPair(ALPN_06);
+ const origin = new OriginProducer();
+ const grant = new Signal(undefined);
+ let answered = () => {};
+ const ready = new Promise((resolve) => {
+ answered = resolve;
+ });
+ const publisher = new Publisher(pair.server, Version.DRAFT_06, randomHop(), origin.consume(), { grant, ready });
+ const broadcast = origin.createBroadcast(Path.from("foo/bar"));
+ broadcast.announce();
+
+ const written: Uint8Array[] = [];
+ const stream = new Stream({
+ version: Version.DRAFT_06,
+ readable: new ReadableStream(),
+ writable: new WritableStream({
+ write(chunk) {
+ written.push(chunk);
+ },
+ }),
+ });
+ const settle = () => new Promise((resolve) => setTimeout(resolve, 10));
+ const running = publisher.runAnnounce(new AnnounceRequest(Path.empty()), stream);
+ await settle();
+ expect(written.length).toBe(0);
+
+ grant.set(grantOf("baz"));
+ answered();
+ await settle();
+ // ANNOUNCE_OK alone: the grant excludes foo/bar, so it never reaches the wire.
+ const bytes = written.flatMap((chunk) => [...chunk]);
+ expect(new TextDecoder().decode(new Uint8Array(bytes))).not.toContain("foo/bar");
+
+ stream.close();
+ await running;
+ publisher.close();
+ broadcast.close();
+ origin.close();
+ pair.client.close();
+ pair.server.close();
+});
+
+// A local close while an announce waits on the setup grant finishes it at once, rather than
+// holding the close until its deadline.
+test("lite draft-06: closing while announces wait for the setup grant does not hang", async () => {
+ const pair = createMockTransportPair(ALPN_06);
+ const origin = new OriginProducer();
+ const grant = new Signal(undefined);
+ const publisher = new Publisher(pair.server, Version.DRAFT_06, randomHop(), origin.consume(), {
+ grant,
+ ready: new Promise(() => {}),
+ });
+
+ const written: Uint8Array[] = [];
+ const stream = new Stream({
+ version: Version.DRAFT_06,
+ readable: new ReadableStream(),
+ writable: new WritableStream({
+ write(chunk) {
+ written.push(chunk);
+ },
+ }),
+ });
+ const running = publisher.runAnnounce(new AnnounceRequest(Path.empty()), stream);
+ await new Promise((resolve) => setTimeout(resolve, 10));
+
+ const drained = await Promise.race([
+ publisher.drain().then(() => "drained" as const),
+ new Promise((resolve) => setTimeout(() => resolve("hung"), 500)),
+ ]);
+ expect(drained).toBe("drained");
+ await running;
+ expect(written.length).toBe(0);
+
+ publisher.close();
+ origin.close();
+ pair.client.close();
+ pair.server.close();
+});
+
+// A TRACK is checked again once its broadcast and track resolve, so a shrink in between
+// refuses it rather than sending TRACK_INFO the grant no longer covers.
+test("lite draft-06: a grant that shrinks while a TRACK resolves refuses it", async () => {
+ const pair = createMockTransportPair(ALPN_06);
+ const origin = new OriginProducer();
+ const handle = origin.dynamic(Path.from("live"));
+ const requests = handle.requested();
+ const grant = new Signal(grantOf("live"));
+ const publisher = new Publisher(pair.server, Version.DRAFT_06, randomHop(), origin.consume(), {
+ grant,
+ ready: Promise.resolve(),
+ });
+
+ const client = await Stream.open(pair.client, { version: Version.DRAFT_06 });
+ const server = await Stream.accept(pair.server, Version.DRAFT_06);
+ if (!server) throw new Error("publisher never accepted the track stream");
+
+ const resets = spyOn(Writer.prototype, "reset");
+ const produced = new BroadcastProducer();
+ produced.createTrack("video", { timescale: Timescale.MILLI });
+ try {
+ const serving = publisher.runTrackInfo(new TrackMessage(Path.from("live/cam"), "video"), server);
+
+ // The TRACK is parked resolving the broadcast when the grant shrinks.
+ const request = await requests.next();
+ if (request.done) throw new Error("the handler never saw the request");
+ grant.set(grantOf("other"));
+ request.value.accept(produced);
+ await serving;
+
+ expect(unauthorizedResets(resets)).toContain("unauthorized: live/cam");
+ } finally {
+ resets.mockRestore();
+ produced.close();
+ handle.close();
+ publisher.close();
+ client.close();
+ origin.close();
+ }
+});
+
+// A TRACK_INFO blocked on flow control when the grant shrinks is reset: the rest of the
+// answer never reaches the wire once the stream drains.
+test("lite draft-06: a grant that shrinks while TRACK_INFO is blocked resets it", async () => {
+ const pair = createMockTransportPair(ALPN_06);
+ const origin = new OriginProducer();
+ const handle = origin.dynamic(Path.from("live"));
+ const requests = handle.requested();
+ const grant = new Signal(grantOf("live"));
+ const publisher = new Publisher(pair.server, Version.DRAFT_06, randomHop(), origin.consume(), {
+ grant,
+ ready: Promise.resolve(),
+ });
+
+ // The first write (the length prefix) parks until released.
+ let release = () => {};
+ const blocked = new Promise((resolve) => {
+ release = resolve;
+ });
+ let parked = () => {};
+ const writing = new Promise((resolve) => {
+ parked = resolve;
+ });
+ const written: Uint8Array[] = [];
+ const stream = new Stream({
+ version: Version.DRAFT_06,
+ readable: new ReadableStream(),
+ writable: new WritableStream({
+ async write(chunk) {
+ parked();
+ await blocked;
+ written.push(chunk);
+ },
+ }),
+ });
+
+ const resets = spyOn(Writer.prototype, "reset");
+ const produced = new BroadcastProducer();
+ produced.createTrack("video", { timescale: Timescale.MILLI });
+ try {
+ const serving = publisher.runTrackInfo(new TrackMessage(Path.from("live/cam"), "video"), stream);
+ const request = await requests.next();
+ if (request.done) throw new Error("the handler never saw the request");
+ request.value.accept(produced);
+ await writing;
+
+ grant.set(grantOf("other"));
+ release();
+ await serving;
+
+ expect(unauthorizedResets(resets)).toContain("unauthorized: live/cam");
+ // Only the in-flight length prefix; the body never followed it.
+ expect(written.length).toBe(1);
+ } finally {
+ resets.mockRestore();
+ produced.close();
+ handle.close();
+ publisher.close();
+ origin.close();
+ pair.client.close();
+ pair.server.close();
+ }
+});
+
+// The grant watch is armed before the first check, so a shrink that lands while the broadcast
+// is still resolving resets the subscription instead of being missed for good.
+test("lite draft-06: a grant that shrinks while the broadcast resolves resets the subscription", async () => {
+ const pair = createMockTransportPair(ALPN_06);
+ const origin = new OriginProducer();
+ const handle = origin.dynamic(Path.from("live"));
+ const requests = handle.requested();
+ const grant = new Signal(grantOf("live"));
+ const publisher = new Publisher(pair.server, Version.DRAFT_06, randomHop(), origin.consume(), {
+ grant,
+ ready: Promise.resolve(),
+ });
+
+ const client = await Stream.open(pair.client, { version: Version.DRAFT_06 });
+ const server = await Stream.accept(pair.server, Version.DRAFT_06);
+ if (!server) throw new Error("publisher never accepted the subscribe stream");
+
+ const resets = spyOn(Writer.prototype, "reset");
+ const produced = new BroadcastProducer();
+ try {
+ const msg = new Subscribe({ id: 0n, broadcast: Path.from("live/cam"), track: "video", priority: 0 });
+ const serving = publisher.runSubscribe(msg, server);
+
+ // The subscription is parked resolving the broadcast when the grant shrinks.
+ const request = await requests.next();
+ if (request.done) throw new Error("the handler never saw the request");
+ grant.set(grantOf("other"));
+ await Promise.resolve();
+ request.value.accept(produced);
+ await serving;
+
+ expect(unauthorizedResets(resets)).toContain("unauthorized: live/cam");
+ } finally {
+ resets.mockRestore();
+ produced.close();
+ handle.close();
+ publisher.close();
+ client.close();
+ origin.close();
+ }
+});
+
+// A fetch holds its grant watch until the last frame: a group can stay open as long as its
+// track, so a check at accept alone would keep serving after a shrink.
+test("lite draft-06: a grant that shrinks mid-fetch resets the fetch", async () => {
+ const pair = createMockTransportPair(ALPN_06);
+ const origin = new OriginProducer();
+ const grant = new Signal(grantOf("live"));
+ const publisher = new Publisher(pair.server, Version.DRAFT_06, randomHop(), origin.consume(), {
+ grant,
+ ready: Promise.resolve(),
+ });
+
+ const broadcast = publish(origin, Path.from("live"));
+ const track = broadcast.createTrack("video", { timescale: Timescale.MILLI });
+ // Left open, so the fetch is still serving when the grant shrinks.
+ const group = new GroupProducer(0);
+ group.writeFrame(textFrame("first"));
+ track.writeGroup(group);
+
+ const client = await Stream.open(pair.client, { version: Version.DRAFT_06 });
+ const server = await Stream.accept(pair.server, Version.DRAFT_06);
+ if (!server) throw new Error("publisher never accepted the fetch stream");
+
+ const resets = spyOn(Writer.prototype, "reset");
+ try {
+ const msg = new Fetch({ broadcast: Path.from("live"), track: "video", priority: 0, group: 0 });
+ const serving = publisher.runFetch(msg, server);
+ // The first frame is on the wire before the grant shrinks.
+ await client.reader.u8();
+
+ grant.set(grantOf("other"));
+ await serving;
+
+ expect(unauthorizedResets(resets)).toContain("unauthorized: live");
+ } finally {
+ resets.mockRestore();
+ group.close();
+ publisher.close();
+ client.close();
+ broadcast.close();
+ origin.close();
+ }
+});
+
test.each([0, 1])("lite draft-07 reports the cached largest position when starting at group %s", async (startGroup) => {
const version = Version.DRAFT_07;
const pair = createMockTransportPair(ALPN_07_WIP);
diff --git a/js/net/src/lite/publisher.ts b/js/net/src/lite/publisher.ts
index 93f7a48348..afeb353c7a 100644
--- a/js/net/src/lite/publisher.ts
+++ b/js/net/src/lite/publisher.ts
@@ -1,13 +1,15 @@
import { type Dispose, type Getter, race, Signal } from "@moq/signals";
+import type { Grant } from "../auth.ts";
+import { enforceGrant } from "../auth_session.ts";
import type * as broadcast from "../broadcast.ts";
import { Withdrawal } from "../connection/withdrawal.ts";
import * as DatagramStream from "../datagram_stream.ts";
-import { error, NotFound, ProtocolViolation, reason, StreamCode, StreamError } from "../error.ts";
+import { error, NotFound, ProtocolViolation, reason, StreamCode, StreamError, unauthorized } from "../error.ts";
import type * as group from "../group.ts";
import { Cost, type Hop, type Route, routesEqual } from "../hop.ts";
import { hiddenBelow, hooks, presented } from "../internal.ts";
import type { Consumer as OriginConsumer } from "../origin.ts";
-import type * as Path from "../path.ts";
+import * as Path from "../path.ts";
import { type Reader, type Stream, Writer } from "../stream.ts";
import { Milli, Timescale, Timestamp } from "../time.ts";
import type * as track from "../track.ts";
@@ -392,6 +394,15 @@ export class Publisher {
#publish?: OriginConsumer;
+ // Our grant: only what it lets us publish is announced and served, and a shrink
+ // withdraws what it no longer covers. Undefined until the peer answers, and forever on a
+ // version without AUTH, which allows everything.
+ #grant?: Getter;
+
+ // Resolves once the tokens this session presented at setup are answered, so nothing is
+ // announced before the grant it would be checked against.
+ #ready: Promise;
+
// TRACK_INFO is immutable per track, so resolve it from the application once
// (via a throwaway subscribe whose info() resolves when the app calls accept)
// and reuse it for every later TRACK request of the same track. Keyed by the
@@ -406,11 +417,21 @@ export class Publisher {
* @param version - Negotiated protocol version
* @param origin - Hop id shared with the Subscriber
* @param publish - The origin whose broadcasts this session serves; omit to publish nothing
+ * @param auth - The union of our tokens' grants, which bounds what we publish, and when the
+ * setup tokens are answered
*
* @internal
*/
- constructor(quic: WebTransport, version: Version, hop: Hop, publish?: OriginConsumer) {
+ constructor(
+ quic: WebTransport,
+ version: Version,
+ hop: Hop,
+ publish?: OriginConsumer,
+ auth?: { grant: Getter; ready: Promise },
+ ) {
this.#quic = quic;
+ this.#grant = auth?.grant;
+ this.#ready = auth?.ready ?? Promise.resolve();
this.version = version;
this.hop = hop;
const origin = publish && wireOf(publish);
@@ -501,24 +522,56 @@ export class Publisher {
await encodeAnnounceBroadcast(stream.writer, { status: "endedId", id }, this.version);
};
+ // A hidden route stays off the wire unless the request opted in.
+ const carries = (covered: Path.Valid) => msg.hidden || !hiddenBelow(msg.prefix, covered);
+
+ // What the peer currently sees: the table under the prefix, less whatever our grant
+ // does not let us publish.
+ const visible = (table: Advertisements): Map => {
+ const out = presented(msg.prefix, table, carries);
+ const grant = this.#grant?.peek();
+ if (grant) {
+ for (const suffix of [...out.keys()]) {
+ if (!grant.publish.matches(Path.join(msg.prefix, suffix))) out.delete(suffix);
+ }
+ }
+ return out;
+ };
+
// Subscribe BEFORE writing anything: every encode below awaits the wire, and a publish
// landing in that window only notifies the listeners already registered. One created
// afterwards would sleep through it, leaving the change unannounced until something
- // unrelated moved.
+ // unrelated moved. A grant change re-diffs the same way, withdrawing what it no longer
+ // covers and announcing what it now does.
// TODO Make a better helper within Signals.
- let dispose!: Dispose;
- let changed = new Promise((resolve) => {
- dispose = this.#advertised.changed(resolve);
- });
-
- // A hidden route stays off the wire unless the request opted in.
- const carries = (covered: Path.Valid) => msg.hidden || !hiddenBelow(msg.prefix, covered);
+ let dispose: Dispose = () => {};
+ const arm = () =>
+ new Promise<"changed">((resolve) => {
+ const table = this.#advertised.changed(() => resolve("changed"));
+ const grant = this.#grant?.changed(() => resolve("changed"));
+ dispose = () => {
+ table();
+ grant?.();
+ };
+ });
+ let changed = arm();
try {
+ // Nothing is announced before the grant it would be checked against. A local close
+ // meanwhile finishes the stream with nothing announced.
+ const ready = this.#ready.then(() => "ready" as const);
+ if ((await race([ready, stream.reader.closed, this.#withdrawal.closing])) !== "ready") {
+ if (this.#withdrawal.closing.peek()) {
+ stream.close();
+ await stream.writer.closed;
+ }
+ return;
+ }
+
const initial = this.#advertised.peek();
if (!initial) return; // closed
- for (const [name, snap] of presented(msg.prefix, initial, carries)) {
+ for (const [name, snap] of visible(initial)) {
active.set(name, snap);
}
@@ -550,22 +603,17 @@ export class Publisher {
}
for (;;) {
- const advertised = await race([changed, stream.reader.closed, this.#withdrawal.closing]);
+ const woke = await race([changed, stream.reader.closed, this.#withdrawal.closing]);
dispose();
- if (!advertised || advertised === true) break;
+ if (woke !== "changed") break;
// Re-arm before reading, so an advertise that lands while we write is not lost.
- changed = new Promise((resolve) => {
- dispose = this.#advertised.changed(resolve);
- });
+ changed = arm();
const latest = this.#advertised.peek();
if (!latest) break;
- const updated = new Map();
- for (const [name, snap] of presented(msg.prefix, latest, carries)) {
- updated.set(name, snap);
- }
+ const updated = visible(latest);
for (const [suffix, snap] of active) {
const cur = updated.get(suffix);
@@ -602,6 +650,50 @@ export class Publisher {
* @internal
*/
async runSubscribe(msg: Subscribe, stream: Stream) {
+ // Serve only what our grant lets us publish, and stop once it no longer does. The
+ // watch is armed before the first check and held to the end, so a shrink while the
+ // broadcast resolves is never missed.
+ const revoked = unauthorized(msg.broadcast);
+ let serving: track.Subscriber | undefined;
+ const watch = this.#watch(msg.broadcast, () => {
+ console.debug(`publish revoked: broadcast=${msg.broadcast} track=${msg.track}`);
+ serving?.close(revoked);
+ stream.abort(revoked);
+ });
+ try {
+ await this.#serveSubscribe(msg, stream, watch, (track) => {
+ serving = track;
+ });
+ } finally {
+ watch.dispose();
+ }
+ }
+
+ // Watch whether our grant still lets us publish `broadcast`, calling `onRevoke` once
+ // it does not. Arm it before the request's first check.
+ #watch(broadcast: Path.Valid, onRevoke: () => void): { revoked: () => boolean; dispose: () => void } {
+ let revoked = false;
+ const dispose =
+ this.#grant?.subscribe(() => {
+ if (revoked || !this.#denied(broadcast)) return;
+ revoked = true;
+ onRevoke();
+ }) ?? (() => {});
+ return { revoked: () => revoked || this.#denied(broadcast), dispose };
+ }
+
+ async #serveSubscribe(
+ msg: Subscribe,
+ stream: Stream,
+ watch: { revoked: () => boolean },
+ serving: (track: track.Subscriber) => void,
+ ) {
+ // Checked before resolving, so a denied request never reaches the origin.
+ if (watch.revoked()) {
+ stream.writer.reset(unauthorized(msg.broadcast));
+ return;
+ }
+
let front: broadcast.Consumer | undefined;
try {
front =
@@ -612,6 +704,8 @@ export class Publisher {
stream.writer.reset(error(err));
return;
}
+ // Revoked while the broadcast resolved: the watch already reset the stream.
+ if (watch.revoked()) return;
if (!front) {
console.debug(`publish unknown: broadcast=${msg.broadcast}`);
stream.writer.reset(new NotFound(`broadcast ${msg.broadcast}`));
@@ -627,6 +721,7 @@ export class Publisher {
end: endGroup === undefined ? undefined : { excluded: endGroup },
},
});
+ serving(track);
positionCursor(track, this.version, msg.startGroup);
hooks.replaceGroups(track, { end: endGroup === undefined ? undefined : { excluded: endGroup } });
@@ -726,6 +821,34 @@ export class Publisher {
stream.writer.reset(new Error("fetch requires moq-lite-05 or newer"));
return;
}
+ // Like a subscription, the fetch holds its watch until the last frame: a group can stay
+ // open as long as its track, so a check at accept alone would keep serving after a shrink.
+ const revoked = unauthorized(msg.broadcast);
+ let fetched: group.Consumer | undefined;
+ const watch = this.#watch(msg.broadcast, () => {
+ console.debug(`fetch revoked: broadcast=${msg.broadcast} track=${msg.track} group=${msg.group}`);
+ fetched?.close(revoked);
+ stream.abort(revoked);
+ });
+ try {
+ await this.#serveFetch(msg, stream, watch, (group) => {
+ fetched = group;
+ });
+ } finally {
+ watch.dispose();
+ }
+ }
+
+ async #serveFetch(
+ msg: Fetch,
+ stream: Stream,
+ watch: { revoked: () => boolean },
+ serving: (group: group.Consumer) => void,
+ ) {
+ if (watch.revoked()) {
+ stream.writer.reset(unauthorized(msg.broadcast));
+ return;
+ }
let front: broadcast.Consumer | undefined;
try {
@@ -742,6 +865,8 @@ export class Publisher {
stream.writer.reset(new NotFound(`broadcast ${msg.broadcast}`));
return;
}
+ // Revoked while the broadcast resolved: the watch already reset the stream.
+ if (watch.revoked()) return;
// The subscriber opened this stream, so its send order only ranked the request. Rank the
// response here, on the same scale as the group streams it competes with.
@@ -763,6 +888,12 @@ export class Publisher {
hold.abort();
}
group = await wireOf(front).fetchGroup(msg.track, msg.group, { priority: msg.priority });
+ serving(group);
+ // Revoked while the group resolved: the watch already reset the stream.
+ if (watch.revoked()) {
+ group.close(unauthorized(msg.broadcast));
+ return;
+ }
await this.#runFetchGroup(group, stream.writer, {
timescale: Timescale(info.timescale),
start: msg.startFrame,
@@ -1013,6 +1144,18 @@ export class Publisher {
*/
async runTrackInfo(msg: TrackMessage, stream: Stream) {
const hold = new AbortController();
+ // Armed before the first check and held until the answer is acknowledged, so a grant
+ // that shrinks while the answer is blocked on flow control resets it, never sends it.
+ const watch = this.#watch(msg.broadcast, () => {
+ console.debug(`track info revoked: broadcast=${msg.broadcast} track=${msg.track}`);
+ hold.abort();
+ stream.writer.reset(unauthorized(msg.broadcast));
+ });
+ if (watch.revoked()) {
+ watch.dispose();
+ stream.writer.reset(unauthorized(msg.broadcast));
+ return;
+ }
// Watched from the start, so a requester leaving while the reply is still blocked on
// flow control lets go of the track too.
void stream.reader.done().then(
@@ -1031,6 +1174,7 @@ export class Publisher {
if (!front) throw new NotFound(`broadcast ${msg.broadcast}`);
const info = await this.#resolveTrackInfo(front, msg.track, hold.signal);
+ if (watch.revoked()) throw unauthorized(msg.broadcast);
await info.encode(stream.writer, this.version);
console.debug(`track info: broadcast=${msg.broadcast} track=${msg.track}`);
stream.writer.close();
@@ -1039,9 +1183,17 @@ export class Publisher {
hold.abort();
console.debug(`track unknown: broadcast=${msg.broadcast} track=${msg.track}`);
stream.writer.reset(error(err));
+ } finally {
+ watch.dispose();
}
}
+ // Whether our grant excludes publishing this broadcast. No grant yet allows it.
+ #denied(broadcast: Path.Valid): boolean {
+ const grant = this.#grant?.peek();
+ return grant !== undefined && !grant.publish.matches(broadcast);
+ }
+
// Resolve (and cache) a track's immutable TRACK_INFO by asking the application.
// `resolveTrackInfo` triggers a TrackRequest the app answers with accept(TrackInfo);
// only the immutable properties are needed (not the groups). Cached because they're
@@ -1369,6 +1521,17 @@ export class Publisher {
}
}
+ /**
+ * Close the session when our origin publishes a broadcast our grant does not cover;
+ * see {@link enforceGrant}.
+ *
+ * @internal
+ */
+ async runEnforce(setupAnswered: Promise): Promise {
+ if (!this.#grant) return;
+ await enforceGrant({ quic: this.#quic, advertised: this.#advertised, grant: this.#grant, setupAnswered });
+ }
+
/**
* Owes the peer `task`, a served SUBSCRIBE, FETCH, or TRACK request, until it ends.
*
diff --git a/js/net/src/lite/setup.ts b/js/net/src/lite/setup.ts
index 932abec81c..0e97ecfe02 100644
--- a/js/net/src/lite/setup.ts
+++ b/js/net/src/lite/setup.ts
@@ -268,7 +268,7 @@ export class Setup {
/** Encode the SETUP message with its size prefix. Throws on pre-lite-05 versions. */
async encode(w: Writer, version: Version): Promise {
Setup.#guard(version);
- return Message.encode(w, (w) => this.#encode(w, version), MAX_SETUP_SIZE);
+ return Message.encode(w, (w) => this.#encode(w, version), { max: MAX_SETUP_SIZE });
}
/** Decode a SETUP message with its size prefix. Throws on pre-lite-05 versions. */
diff --git a/js/net/src/lite/stream.ts b/js/net/src/lite/stream.ts
index 9060566f2b..8907a90604 100644
--- a/js/net/src/lite/stream.ts
+++ b/js/net/src/lite/stream.ts
@@ -16,6 +16,7 @@ export const StreamId = {
Probe: 4,
Goaway: 5,
Track: 6,
+ Auth: 7,
ClientCompat: 0x20,
ServerCompat: 0x21,
} as const;
diff --git a/js/net/src/lite/subscriber.test.ts b/js/net/src/lite/subscriber.test.ts
index 0caaeb51be..0d9bfeaa61 100644
--- a/js/net/src/lite/subscriber.test.ts
+++ b/js/net/src/lite/subscriber.test.ts
@@ -1,5 +1,6 @@
import { expect, jest, spyOn, test } from "bun:test";
import { Signal } from "@moq/signals";
+import type { Grant } from "../auth.ts";
import type { Probe as ProbeStats } from "../connection/stats.ts";
import * as Epoch from "../epoch.ts";
import { error, fromTransport, reason, StreamCode, StreamError } from "../error.ts";
@@ -990,6 +991,42 @@ test("a fetch started after the subscriber closes rejects without opening a stre
expect(streams.length).toBe(0);
});
+// The grant watch is armed before the first check, so a shrink while TRACK_INFO is still in
+// flight refuses the subscription rather than opening a SUBSCRIBE the grant no longer covers.
+test("a grant that shrinks while the subscription sets up refuses it", async () => {
+ const { quic, streams } = fakeSession();
+ const scoped = (prefix: string): Grant => ({
+ publish: new Path.Patterns([]),
+ subscribe: new Path.Patterns([Path.Pattern.subtree(prefix)]),
+ });
+ const grant = new Signal(scoped("room"));
+ const subscriber = new Subscriber(
+ quic,
+ Version.DRAFT_05,
+ HopSchema.parse(1n),
+ undefined,
+ undefined,
+ undefined,
+ grant,
+ );
+
+ const track = subscriber.consume(Path.from("room/cam")).track("video").subscribe().ordered();
+ const next = track.nextGroup().catch((err: unknown) => err);
+
+ // Parked on TRACK_INFO when the grant shrinks, which ends the TRACK exchange too.
+ await drainUntil(() => streams.length === 1);
+ await streams[0].reading;
+ grant.set(scoped("other"));
+
+ const err = await next;
+ expect(err).toBeInstanceOf(StreamError);
+ expect((err as StreamError).code).toBe(StreamCode.Unauthorized);
+ // Nothing past the TRACK stream reached the wire.
+ expect(streams.length).toBe(1);
+
+ track.close();
+});
+
test("an already-aborted fetch rejects without opening a stream", async () => {
const { quic, streams } = fakeSession();
const subscriber = new Subscriber(quic, Version.DRAFT_05, HopSchema.parse(1n));
diff --git a/js/net/src/lite/subscriber.ts b/js/net/src/lite/subscriber.ts
index 726c93f037..478109f275 100644
--- a/js/net/src/lite/subscriber.ts
+++ b/js/net/src/lite/subscriber.ts
@@ -1,12 +1,23 @@
-import { type Dispose, type GetPromise, race, Signal } from "@moq/signals";
+import { type Dispose, type GetPromise, type Getter, race, Signal } from "@moq/signals";
import * as announce from "../announced.ts";
+import type { Grant } from "../auth.ts";
import * as broadcast from "../broadcast.ts";
import type { Drain } from "../connection/goaway.ts";
import type { Probe as ProbeStats } from "../connection/stats.ts";
import { BroadcastCache } from "../consume.ts";
import * as DatagramStream from "../datagram_stream.ts";
import type * as Epoch from "../epoch.ts";
-import { controlTimeout, error, ProtocolViolation, reason, StreamCode, StreamError, sessionCause } from "../error.ts";
+import {
+ closeReason,
+ controlTimeout,
+ error,
+ ProtocolViolation,
+ reason,
+ StreamCode,
+ StreamError,
+ sessionCause,
+ unauthorized,
+} from "../error.ts";
import * as netGroup from "../group.ts";
import { Cost, type Hop, MAX_HOPS, type Route, routesEqual, UNKNOWN_HOP } from "../hop.ts";
import { groupBounds, hiddenBelow, scopeCaptures, scopeHead, scopeOverlaps } from "../internal.ts";
@@ -78,6 +89,18 @@ function supportsTrackStream(version: Version): boolean {
}
}
+// What a subscription has set up so far, so a revocation (or a timeout) can reach it at
+// any stage.
+interface SubscribeSetup {
+ stream?: Stream;
+ track?: Stream;
+ producer?: track.Producer;
+ // Ends a setup still running once the deadline passed or the grant was revoked.
+ cancel: AbortController;
+ // Set once our grant stops covering the broadcast.
+ revoked?: Error;
+}
+
interface SubscribeEntry {
// The write side: incoming GROUP streams are routed here. The application reads
// the matching track.Subscriber it got from broadcast.Consumer.subscribe.
@@ -117,20 +140,6 @@ interface SubscribeEntry {
// two report the same thing, where the default 0 would tell the peer it closed cleanly.
const PROTOCOL_VIOLATION_CODE = 15;
-// WebTransport rejects a close reason over 1024 bytes of UTF-8 by throwing, so a reason
-// built from peer-supplied data has to be bounded before it gets there. A broadcast path
-// is peer-supplied and long enough to reach this on its own.
-const MAX_CLOSE_REASON = 1024;
-
-// The longest prefix of `text` that fits a close reason. `encodeInto` stops on a whole
-// code point, so `read` never lands mid-character the way slicing bytes would.
-function closeReason(text: string): string {
- const encoder = new TextEncoder();
- const buf = new Uint8Array(MAX_CLOSE_REASON);
- const { read } = encoder.encodeInto(text, buf);
- return text.slice(0, read);
-}
-
export class Subscriber {
#quic: WebTransport;
@@ -170,6 +179,11 @@ export class Subscriber {
// Distinguishes failures from streams torn down by Subscriber.close().
#closed = new AbortController();
+
+ // Our grant: a subscription it stops covering is cancelled. Undefined until the peer
+ // answers, and forever on a version without AUTH, which allows everything.
+ #grant?: Getter;
+
/**
* Creates a new Subscriber instance.
* @param quic - The WebTransport session to use
@@ -178,6 +192,7 @@ export class Subscriber {
* @param probe - Optional sink for the peer's PROBE estimates
* @param peerSetup - Optional peer SETUP slot for capability gating (lite-05+)
* @param goaway - Settles when the peer sends GOAWAY
+ * @param grant - The union of our tokens' grants, which bounds what we subscribe to
*
* @internal
*/
@@ -188,6 +203,7 @@ export class Subscriber {
probe?: Signal,
peerSetup?: Signal,
goaway?: GetPromise,
+ grant?: Getter,
) {
this.#quic = quic;
this.version = version;
@@ -195,6 +211,7 @@ export class Subscriber {
this.#probe = probe;
this.#peerSetup = peerSetup;
this.#goaway = goaway;
+ this.#grant = grant;
}
// Whether the peer has sent GOAWAY. Requests keep opening here until a replacement
@@ -209,6 +226,12 @@ export class Subscriber {
return this.#goingAway() ? DRAIN_COST : (cost ?? Cost.zero);
}
+ // Whether our grant no longer lets us subscribe to `broadcast`.
+ #denied(broadcast: Path.Valid): boolean {
+ const grant = this.#grant?.peek();
+ return grant !== undefined && !grant.subscribe.matches(broadcast);
+ }
+
/**
* Subscribe to broadcast announcements matching `scope`. Paths are relative
* to the session, not the scope.
@@ -560,6 +583,37 @@ export class Subscriber {
request.reject(new Error(EMPTY_RANGE));
return;
}
+ const refused = unauthorized(broadcast);
+ // Armed before the first check and held to the end, so a shrink while the
+ // subscription sets up is never missed: it resets whatever reached the wire.
+ const state: SubscribeSetup = { cancel: new AbortController() };
+ const disposeGrant = this.#grant?.subscribe(() => {
+ if (state.revoked || !this.#denied(broadcast)) return;
+ state.revoked = refused;
+ console.debug(`subscribe revoked: id=${id} broadcast=${broadcast} track=${request.name}`);
+ state.cancel.abort(refused);
+ state.producer?.close(refused);
+ state.stream?.abort(refused);
+ });
+ try {
+ if (this.#denied(broadcast)) {
+ request.reject(refused);
+ return;
+ }
+ await this.#serveSubscribe(id, broadcast, epoch, request, state);
+ } finally {
+ disposeGrant?.();
+ }
+ }
+
+ async #serveSubscribe(
+ id: bigint,
+ broadcast: Path.Valid,
+ epoch: Epoch.Valid | undefined,
+ request: track.Request,
+ state: SubscribeSetup,
+ ) {
+ const subscription = request.subscription;
// `timescale` stays undefined until TRACK_INFO (or, on older drafts,
// implicit defaults) resolves it; runGroup blocks on it before decoding.
@@ -583,9 +637,6 @@ export class Subscriber {
// so the timeout path can close them if they finish opening after the deadline,
// and `cancel` ends a setup still running once the deadline passed, resetting
// its TRACK stream so a peer that never answers can't hold one per attempt.
- const state: { stream?: Stream; track?: Stream; cancel: AbortController } = {
- cancel: new AbortController(),
- };
const setup = this.#openSubscribe(state, msg, request, id, timescale);
let opened: { stream: Stream; entry: SubscribeEntry };
@@ -598,8 +649,10 @@ export class Subscriber {
console.debug(`subscribe ok: id=${id} broadcast=${broadcast} track=${request.name}`);
} catch (err) {
// The setup outlived its deadline waiting for the first response: a control
- // timeout, not content that arrived late.
- const e = err instanceof TimeoutError ? controlTimeout(err) : await sessionCause(this.#quic, err);
+ // timeout, not content that arrived late. A revocation says so instead.
+ const e =
+ state.revoked ??
+ (err instanceof TimeoutError ? controlTimeout(err) : await sessionCause(this.#quic, err));
state.cancel.abort(e);
request.reject(e);
this.#subscribes.delete(id);
@@ -690,7 +743,7 @@ export class Subscriber {
// SUBSCRIBE_OK). Older drafts carry no per-track properties, so they resolve to
// defaults and just drain SUBSCRIBE_OK.
async #openSubscribe(
- state: { stream?: Stream; track?: Stream; cancel: AbortController },
+ state: SubscribeSetup,
msg: Subscribe,
request: track.Request,
id: bigint,
@@ -732,6 +785,7 @@ export class Subscriber {
held: state.track,
};
this.#subscribes.set(id, entry);
+ state.producer = producer;
state.stream = await Stream.open(this.#quic, { version: this.version });
// The deadline passed while the open waited: the late-setup handler resets the stream, so
diff --git a/js/net/src/lite/version.ts b/js/net/src/lite/version.ts
index 55ae963947..e56e299b61 100644
--- a/js/net/src/lite/version.ts
+++ b/js/net/src/lite/version.ts
@@ -10,7 +10,7 @@ export const Version = {
/// Also adds frame-precise subscribe/fetch bounds and a GROUP frame offset.
DRAFT_06: 0xff0dad06,
/// Work-in-progress lite-07, only negotiated when explicitly offered.
- /// Adds the ANNOUNCE_REQUEST hidden opt-in and the publisher epoch.
+ /// Adds the ANNOUNCE_REQUEST hidden opt-in, the publisher epoch, and the Auth Stream.
DRAFT_07: 0xff0dad07,
} as const;
@@ -65,6 +65,25 @@ export function hasDatagrams(version: Version): boolean {
}
}
+/**
+ * Whether either endpoint may open an Auth Stream (0x7) to present a token and learn its
+ * grant. Added in lite-07.
+ */
+export function hasAuth(version: Version): boolean {
+ // Explicitly list older versions so future versions default to carrying AUTH.
+ switch (version) {
+ case Version.DRAFT_01:
+ case Version.DRAFT_02:
+ case Version.DRAFT_03:
+ case Version.DRAFT_04:
+ case Version.DRAFT_05:
+ case Version.DRAFT_06:
+ return false;
+ default:
+ return true;
+ }
+}
+
/** Whether announce streams begin with ANNOUNCE_OK and omit the sender's origin from each hop chain. */
export function hasAnnounceOk(version: Version): boolean {
// Explicitly list older versions so future versions keep the lite-05+ announce behavior.
diff --git a/js/pattern/src/index.ts b/js/pattern/src/index.ts
index f5e367902c..a3c31cde8b 100644
--- a/js/pattern/src/index.ts
+++ b/js/pattern/src/index.ts
@@ -402,7 +402,9 @@ export class Pattern {
*/
static parse(text: string): Pattern {
if (text === "") return new Pattern([]);
- return new Pattern(text.split("/").map(parseSegment));
+ // One past the limit is enough for the constructor to refuse, without splitting
+ // a peer's megabytes of `a/a/...` into segments first.
+ return new Pattern(text.split("/", MAX_PATTERN_SEGMENTS + 1).map(parseSegment));
}
/** A pattern from its segments, validating the grammar. Throws {@link InvalidPattern}. */
diff --git a/quest/m1/auth/README.md b/quest/m1/auth/README.md
index d9be181e76..ad1270707a 100644
--- a/quest/m1/auth/README.md
+++ b/quest/m1/auth/README.md
@@ -71,7 +71,9 @@ Decisions settled while planning, recorded so review does not relitigate them:
token like any client; AUTH reports the grant actually admitted in each
direction. The next mTLS scope quest can restrict or refuse it. A v1 endpoint
must explicitly grant `**` for unrestricted access; AUTH does not widen a
- scoped grant because the caller is another relay.
+ scoped grant because the caller is another relay. Mesh dials use the default
+ version set, which leaves out `moq-lite-07-wip`, so this applies once lite-07
+ is cut or a cluster opts in.
- **Client API.** Tokens live on `moq_tokio::connect::Config`, the
dial-side config. `Connection::auth()` is a handle the connection owns: it
keeps every added token, presents them on each session as it reconnects,
@@ -89,8 +91,10 @@ Decisions settled while planning, recorded so review does not relitigate them:
Everything here is additive: `Session::auth()` is new, and the relay derives
the grant from the origin handles it already scopes. Decided 2026-10-05: wire
work targets the wip lite version until the maintainer cuts it, never a
-published version in place, so AUTH and its stream code land in
-`moq-lite-07-wip` (or whichever version is wip then), not lite-06.
+published version in place, so AUTH lands in `moq-lite-07-wip` (or whichever
+version is wip then), not lite-06. The UNAUTHORIZED stream code stays on lite-06
+(#5004), since a relay can narrow a session with no Auth Stream; it is 0x3B,
+because `main` assigned 0x3A to NOT_FETCHABLE first.
## Required
@@ -101,6 +105,8 @@ published version in place, so AUTH and its stream code land in
non-canonical grant pattern, or an out-of-range `Expires`, closes the
session with PROTOCOL_VIOLATION in Rust and JS
- [JS fetch grant watch](/quest/m1/auth/js-fetch-watch.md) - a JS `fetchGroup` ends `Unauthorized` when its path leaves the grant
+- [Request gate](/quest/m1/auth/request-gate.md) - every incoming request is
+ refused up front and held to the grant through a gate its dispatcher owns
- [Relay tokens](/quest/m1/auth/relay-refresh.md) - the relay verifies tokens
sent in band, unions their grants, and cancels only work that loses access
- [Request tokens](/quest/m1/auth/request-token.md) - an `AUTHORIZATION
diff --git a/quest/m1/auth/request-gate.md b/quest/m1/auth/request-gate.md
new file mode 100644
index 0000000000..d03bc56f96
--- /dev/null
+++ b/quest/m1/auth/request-gate.md
@@ -0,0 +1,37 @@
+# [M] Every incoming request is held to the grant by construction
+
+## Goal
+
+Every request a peer sends, on lite and IETF, in Rust and `@moq/net`, is
+checked against the grant when it is dispatched and held to it for its whole
+life: resolution, reads, and writes. A new request type cannot be served
+without a gate. Per-handler checks already missed standalone FETCH and
+TRACK_STATUS in Rust and the TRACK re-check and setup announces in JS, all
+fixed in #4039, as were the joining FETCH and the TRACK_STATUS and TRACK_INFO
+answers while they are written. Behavior stays the same: no wire or public API
+change, and the existing auth tests are the regression net.
+
+## Plan
+
+Decisions settled while planning (2026-10-09):
+
+- **The dispatcher owns the gate.** It refuses an out-of-grant request up
+ front, the same way for every type, and hands the live gate to the handler,
+ whose signature requires one. The handler still picks its graceful end on a
+ narrowed grant (PUBLISH_DONE `Unauthorized`, a REQUEST_ERROR, or a stream
+ reset once FETCH_OK is out). Racing the whole handler future and dropping it
+ was rejected because it loses those graceful refusals.
+- **Rust and JS together**, so the shape and names stay mirrored.
+- **Structure only.** Finding new gaps is not the goal, but any request type
+ the refactor shows was ungated gets a regression test in the same PR.
+
+Today `auth::Gate` is built inside each handler: the IETF publisher's
+SUBSCRIBE, standalone FETCH, and TRACK_STATUS; the IETF subscriber; and the
+lite publisher and subscriber. JS checks `#denied` and `#watch` per handler.
+A joining FETCH carries its subscription's namespace and holds its own gate,
+since the cache it answers from outlives the subscription.
+
+## Related
+
+- [JS fetch grant watch](/quest/m1/auth/js-fetch-watch.md) - the outgoing
+ side: a JS `fetchGroup` this session issued ends when its path leaves the grant
diff --git a/rs/moq-c/src/error.rs b/rs/moq-c/src/error.rs
index c0c0f6b10c..165a2dde75 100644
--- a/rs/moq-c/src/error.rs
+++ b/rs/moq-c/src/error.rs
@@ -401,6 +401,7 @@ fn stream_kind(err: &moq_net::StreamError) -> moq_protocol_kind {
moq_net::StreamError::WrongSize => MOQ_PROTOCOL_KIND_WRONG_SIZE,
moq_net::StreamError::FrameTooLarge => MOQ_PROTOCOL_KIND_FRAME_TOO_LARGE,
moq_net::StreamError::TimestampMismatch => MOQ_PROTOCOL_KIND_TIMESTAMP_MISMATCH,
+ moq_net::StreamError::Unauthorized => MOQ_PROTOCOL_KIND_UNAUTHORIZED,
moq_net::StreamError::App(_) => MOQ_PROTOCOL_KIND_APP,
moq_net::StreamError::Unknown(_) => MOQ_PROTOCOL_KIND_UNKNOWN,
_ => MOQ_PROTOCOL_KIND_UNKNOWN,
diff --git a/rs/moq-c/src/test.rs b/rs/moq-c/src/test.rs
index b9068fce03..032e59845c 100644
--- a/rs/moq-c/src/test.rs
+++ b/rs/moq-c/src/test.rs
@@ -367,6 +367,12 @@ fn last_error_protocol_captures_session_known_app_and_unknown() {
0x2,
moq_protocol_kind::MOQ_PROTOCOL_KIND_UNAUTHORIZED as u32,
),
+ (
+ moq_net::Error::from(moq_net::StreamError::Unauthorized),
+ moq_error_scope::MOQ_ERROR_SCOPE_STREAM as u32,
+ 0x3b,
+ moq_protocol_kind::MOQ_PROTOCOL_KIND_UNAUTHORIZED as u32,
+ ),
(
moq_net::Error::from(moq_net::SessionError::App(7)),
moq_error_scope::MOQ_ERROR_SCOPE_SESSION as u32,
diff --git a/rs/moq-ffi/src/error.rs b/rs/moq-ffi/src/error.rs
index 58f857282d..3602c0c4da 100644
--- a/rs/moq-ffi/src/error.rs
+++ b/rs/moq-ffi/src/error.rs
@@ -25,7 +25,7 @@ pub enum MoqProtocolKind {
Cancel,
/// Something went wrong that isn't worth a dedicated code. Session 1, stream 0.
Internal,
- /// The peer's token does not grant the requested path or operation.
+ /// The peer's token does not grant the requested path or operation. Session 2, stream 0x3B.
Unauthorized,
/// The peer broke a protocol rule; the session is unusable.
ProtocolViolation,
@@ -144,6 +144,7 @@ fn stream_kind(err: &moq_net::StreamError) -> MoqProtocolKind {
moq_net::StreamError::WrongSize => MoqProtocolKind::WrongSize,
moq_net::StreamError::FrameTooLarge => MoqProtocolKind::FrameTooLarge,
moq_net::StreamError::TimestampMismatch => MoqProtocolKind::TimestampMismatch,
+ moq_net::StreamError::Unauthorized => MoqProtocolKind::Unauthorized,
moq_net::StreamError::App(_) => MoqProtocolKind::App,
moq_net::StreamError::Unknown(_) => MoqProtocolKind::Unknown,
_ => MoqProtocolKind::Unknown,
@@ -475,6 +476,19 @@ mod tests {
}
}
+ #[test]
+ fn stream_unauthorized_keeps_its_kind() {
+ let err = MoqError::from(moq_net::Error::from(moq_net::StreamError::Unauthorized));
+ match err {
+ MoqError::Protocol { details: protocol } => {
+ assert_eq!(protocol.scope, MoqErrorScope::Stream);
+ assert_eq!(protocol.code, 0x3b);
+ assert_eq!(protocol.kind, MoqProtocolKind::Unauthorized);
+ }
+ other => panic!("expected Protocol, got {other}"),
+ }
+ }
+
#[test]
fn stream_app_code_is_verbatim() {
let err = MoqError::from(moq_net::Error::from(moq_net::StreamError::App(7)));
diff --git a/rs/moq-net/benches/origin.rs b/rs/moq-net/benches/origin.rs
index 82c75de8f5..63cbffd81d 100644
--- a/rs/moq-net/benches/origin.rs
+++ b/rs/moq-net/benches/origin.rs
@@ -9,6 +9,11 @@
//! down to its prefix and beneath it. Competing routes at that prefix are
//! ranked once per change, then each cursor selects its first visible entry.
//!
+//! `origin/narrow*` put a session in front of the origin, since a narrowing is
+//! enforced by the session serving it: every subscription holds a gate on the
+//! session's grant, and a narrowing wakes each one. The baseline is a session
+//! that never narrows.
+//!
//! An equal-cost pool is swept the same way, over its members and the paths it
//! already serves.
//!
@@ -17,12 +22,16 @@
//!
//! Run with `cargo bench -p moq-net --bench origin`.
+#[path = "../tests/support/mod.rs"]
+mod support;
+
use std::task::Poll;
use std::time::Duration;
use criterion::{BatchSize, BenchmarkId, Criterion, Throughput, criterion_group, criterion_main};
use futures::FutureExt;
-use moq_net::{Epoch, Hop, Hops, Pattern, Patterns, Timestamp, announce, broadcast, kio, origin, track};
+use moq_net::{Epoch, Hop, Hops, Pattern, Patterns, Timestamp, announce, auth, broadcast, kio, origin, track};
+use support::harness::{MockConnectOptions, MockPair, connect_mock};
/// `(publishers, subscribers)` shapes for the fan-out benchmarks.
const SHAPES: [(usize, usize); 3] = [(100, 10), (1_000, 100), (1_000, 1_000)];
@@ -610,6 +619,152 @@ fn bench_handoff(c: &mut Criterion) {
group.finish();
}
+/// `(routes, subscribers)` shapes for the narrowing benchmarks: [`SHAPES`] plus a
+/// row that grows the routes alone, so each axis shows its own slope.
+const NARROW_SHAPES: [(usize, usize); 4] = [(100, 10), (1_000, 10), (1_000, 100), (1_000, 1_000)];
+
+/// A relay serving `routes` broadcasts to one client session subscribed to
+/// `subscribers` of them. The first subscription is the one a narrowing to
+/// `room/**` ends: its broadcast sits at `muted` instead.
+struct Watching {
+ pair: MockPair,
+ tracks: Vec,
+ subscriptions: Vec,
+ _broadcasts: Vec,
+ _origins: [origin::Producer; 2],
+}
+
+fn watched_path(index: usize) -> String {
+ match index {
+ 0 => "muted".to_string(),
+ index => format!("room/{index}"),
+ }
+}
+
+async fn watching(routes: usize, subscribers: usize) -> Watching {
+ let spawn = |hop| {
+ let (producer, driver) = origin::Producer::new(origin::Config::new(Hop::new(hop).unwrap()));
+ support::harness::spawn(driver);
+ producer
+ };
+ let relay = spawn(1);
+ let received = spawn(2);
+
+ let mut broadcasts = Vec::new();
+ let mut tracks = Vec::new();
+ for index in 0..routes {
+ let broadcast = relay.publish(watched_path(index), origin::Route::default()).unwrap();
+ tracks.push(broadcast.create_track("video", None).unwrap());
+ broadcasts.push(broadcast);
+ }
+
+ let mut options = MockConnectOptions::new("moq-lite-06".parse().unwrap());
+ options.server_publish = Some(relay.consume());
+ options.client_subscribe = Some(received.clone());
+ let pair = connect_mock(options).await;
+
+ let mut subscriptions = Vec::new();
+ for index in 0..subscribers {
+ let remote = received.consume().routed_broadcast(watched_path(index)).await.unwrap();
+ let subscription = remote.track("video").unwrap().subscribe(None).await.unwrap();
+ subscriptions.push(subscription);
+ }
+ // One round, so every subscription is live end to end before anything is timed.
+ let mut watching = Watching {
+ pair,
+ tracks,
+ subscriptions,
+ _broadcasts: broadcasts,
+ _origins: [relay, received],
+ };
+ watching.round().await;
+ watching
+}
+
+impl Watching {
+ /// Every subscribed track writes a group, and every subscription reads it.
+ async fn round(&mut self) {
+ for track in &self.tracks[..self.subscriptions.len()] {
+ let mut group = track.append_group().unwrap();
+ group.write_frame(Timestamp::ZERO, b"frame".as_ref()).unwrap();
+ group.finish().unwrap();
+ }
+ for subscription in &mut self.subscriptions {
+ subscription.recv_group().await.unwrap().expect("track ended");
+ }
+ }
+}
+
+fn runtime() -> tokio::runtime::Runtime {
+ tokio::runtime::Builder::new_current_thread()
+ .enable_time()
+ .build()
+ .unwrap()
+}
+
+/// Everything under `room/`, which leaves out only the `muted` subscription.
+fn room() -> auth::Grant {
+ auth::Grant {
+ subscribe: Pattern::subtree("room").unwrap().into(),
+ ..auth::Grant::all()
+ }
+}
+
+/// Steady-state delivery through a session's gates: one round across every
+/// subscription, on a session that never narrows and on one narrowed to a grant
+/// that still covers them all. Neither may depend on `routes`.
+fn bench_narrow_delivery(c: &mut Criterion) {
+ let mut group = c.benchmark_group("origin/narrow_delivery");
+ let rt = runtime();
+ for (routes, subscribers) in NARROW_SHAPES {
+ group.throughput(Throughput::Elements(subscribers as u64));
+ for narrowed in [false, true] {
+ let label = if narrowed { "narrowed" } else { "never" };
+ let id = BenchmarkId::new(label, format!("{routes}r_{subscribers}s"));
+ group.bench_function(id, |b| {
+ let mut watching = rt.block_on(watching(routes, subscribers));
+ if narrowed {
+ // Covers every subscription but the muted one, which is not subscribed here.
+ let mut grant = room();
+ grant.subscribe.insert(Pattern::literal("muted").unwrap());
+ watching.pair.server.auth().authorize(&grant);
+ }
+ b.iter(|| rt.block_on(watching.round()));
+ });
+ }
+ }
+ group.finish();
+}
+
+/// One narrowing on a live session: from `narrow` until the subscription it
+/// excludes resets. It wakes every subscription's gate and re-checks every route
+/// the session announced, so it may grow with the session's own `subscribers`
+/// and `routes`, never with anything else on the origin.
+fn bench_narrow(c: &mut Criterion) {
+ let mut group = c.benchmark_group("origin/narrow");
+ group.sample_size(10);
+ group.sampling_mode(criterion::SamplingMode::Flat);
+ let rt = runtime();
+ for (routes, subscribers) in NARROW_SHAPES {
+ let id = BenchmarkId::from_parameter(format!("{routes}r_{subscribers}s"));
+ group.bench_function(id, |b| {
+ b.iter_custom(|iters| {
+ let mut elapsed = Duration::ZERO;
+ for _ in 0..iters {
+ let mut watching = rt.block_on(watching(routes, subscribers));
+ let start = std::time::Instant::now();
+ watching.pair.server.auth().authorize(&room());
+ let ended = rt.block_on(watching.subscriptions[0].recv_group());
+ elapsed += start.elapsed();
+ assert!(ended.is_err(), "the muted subscription outlived the narrowing");
+ }
+ elapsed
+ });
+ });
+ }
+ group.finish();
+}
+
/// `(tracks, readers per track)` shapes for [`bench_relay`].
const RELAY: [(usize, usize); 4] = [(1, 1), (1, 100), (100, 1), (10, 100)];
@@ -882,6 +1037,8 @@ criterion_group!(
bench_retire,
bench_pool_churn,
bench_handoff,
+ bench_narrow_delivery,
+ bench_narrow,
bench_relay,
bench_parked,
bench_copy_walk
diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs
new file mode 100644
index 0000000000..fe99890a0b
--- /dev/null
+++ b/rs/moq-net/src/auth.rs
@@ -0,0 +1,1791 @@
+//! In-band authorization: present tokens to the peer and learn what they grant.
+//!
+//! Each side of a session presents the credential its connection
+//! already carried (the URL, a client certificate, or nothing) right after
+//! setup, and learns the [`Grant`] it earned. [`Session::auth`](crate::Session::auth)
+//! returns the [`Handle`]: [`grant`](Handle::grant) is the union of every token
+//! this side presented, and [`add`](Handle::add) presents another without
+//! reconnecting. Dropping the returned [`Token`] withdraws it.
+//!
+//! The other direction, answering the peer's tokens, is automatic: the session
+//! grants what its own origin handles allow. An application that verifies tokens
+//! itself takes [`requests`](Handle::requests) before running the session's
+//! driver, and then answers every token the peer presents. Either way,
+//! [`authorize`](Handle::authorize) re-authorizes the peer on a live session, narrower
+//! or wider, on every version, whether or not it speaks AUTH.
+//!
+//! moq-transport draft-17+ carries the same exchange when both sides negotiate the
+//! MoQ Auth extension. Older versions, and peers that do not negotiate it, carry no
+//! AUTH exchange: there the grant stays `None` and [`add`](Handle::add) fails with
+//! [`Error::Unsupported`]. moq-lite carries a grant's patterns as they are;
+//! moq-transport carries namespace prefixes, so it refuses a grant that is not a
+//! union of subtrees rather than widen it.
+
+use std::{
+ collections::{BTreeMap, VecDeque},
+ task::Poll,
+};
+
+use bytes::Bytes;
+
+use crate::{Error, Patterns, Result, SessionError, time::Instant};
+
+/// `ready!` for a `kio::Shared` poll, which yields a guard rather than a result.
+macro_rules! ready_or {
+ ($e:expr) => {
+ match $e {
+ Poll::Ready(guard) => guard,
+ Poll::Pending => return Poll::Pending,
+ }
+ };
+}
+
+/// What a peer lets this side do, in this side's own paths.
+///
+/// The paths are relative to the session's root, the same paths the session
+/// announces and subscribes with. `Grant::default()` grants nothing.
+#[derive(Clone, Debug, Default, PartialEq, Eq)]
+pub struct Grant {
+ /// The broadcasts this side may publish to the peer.
+ pub publish: Patterns,
+ /// The broadcasts this side may subscribe to from the peer.
+ pub subscribe: Patterns,
+ /// When the grant lapses, or `None` for never.
+ pub expires: Option,
+}
+
+impl Grant {
+ /// A grant of everything that never expires.
+ pub fn all() -> Self {
+ Self {
+ publish: crate::Pattern::all().into(),
+ subscribe: crate::Pattern::all().into(),
+ expires: None,
+ }
+ }
+
+ /// Fold `other` into this grant: the paths either allows, lapsing at the
+ /// earlier expiry, which is when the union next shrinks.
+ fn union(&mut self, other: &Self) {
+ self.publish.extend(other.publish.iter().cloned());
+ self.subscribe.extend(other.subscribe.iter().cloned());
+ self.expires = earliest(self.expires, other.expires);
+ }
+
+ /// The paths both grants allow, lapsing at the earlier expiry.
+ ///
+ /// Fails closed: an intersection too large to hold grants nothing, never more.
+ fn intersect(&self, other: &Self) -> Self {
+ let both = |a: &Patterns, b: &Patterns| {
+ a.intersect(b).unwrap_or_else(|err| {
+ tracing::warn!(%err, "grant intersection too large; granting nothing");
+ Patterns::new()
+ })
+ };
+ Self {
+ publish: both(&self.publish, &other.publish),
+ subscribe: both(&self.subscribe, &other.subscribe),
+ expires: earliest(self.expires, other.expires),
+ }
+ }
+}
+
+/// The earlier of two optional deadlines, where `None` is never.
+fn earliest(a: Option, b: Option) -> Option {
+ match (a, b) {
+ (Some(a), Some(b)) => Some(a.min(b)),
+ (a, b) => a.or(b),
+ }
+}
+
+/// The shared state behind every auth handle of one session.
+#[derive(Default)]
+pub(crate) struct State {
+ /// Whether the negotiated version carries AUTH at all.
+ supported: bool,
+ /// The session speaks moq-lite, whose requests carry no token.
+ lite: bool,
+ /// Set once the session ends; every waiter resolves with it.
+ closed: Option,
+ next_id: u64,
+ /// Every token this side presented that has not ended.
+ tokens: BTreeMap,
+ /// Tokens waiting for the driver to open their stream.
+ opening: VecDeque,
+ /// The union of every open token's grant, `None` until the peer first replies.
+ union: Option,
+ /// The peer replied to some token, so the union is known even when empty.
+ replied: bool,
+ /// The most this side lets the peer do, in the peer's terms: `None` (only the
+ /// origin handles bound it) until [`Handle::authorize`] sets it; the grant it was
+ /// last called with.
+ limit: Option,
+ /// Bumped whenever the union or the limit changes, so the session's per-stream
+ /// gates can skip re-matching paths on every wakeup.
+ epoch: u64,
+ acceptor: Acceptor,
+}
+
+/// One presented token.
+pub(crate) struct Slot {
+ token: Bytes,
+ /// Presented by the session itself at setup, so enforcement waits on it.
+ setup: bool,
+ /// The latest AUTH_OK, `None` before the first and after the token ends.
+ grant: Option,
+ /// The first reply: `Ok` for an AUTH_OK, the refusal otherwise.
+ answered: Option>,
+ /// Why the token ended, once it has.
+ ended: Option,
+ /// The [`Token`] handle dropped: the driver closes the stream.
+ withdrawn: bool,
+}
+
+/// Who answers the peer's tokens. Decided once, when the driver first runs.
+#[derive(Default)]
+enum Acceptor {
+ /// Nobody asked yet; the app may still take [`Handle::requests`].
+ #[default]
+ Undecided,
+ /// The app answers every token.
+ App(kio::Queue),
+ /// The session answers from its own origin handles.
+ Default,
+}
+
+impl State {
+ fn recompute(&mut self) {
+ let mut granted = self.tokens.values().filter_map(|slot| slot.grant.as_ref()).peekable();
+ if granted.peek().is_none() && !self.replied {
+ return;
+ }
+ let mut union = Grant::default();
+ for grant in granted {
+ union.union(grant);
+ }
+ if self.union.as_ref() != Some(&union) {
+ self.union = Some(union);
+ self.epoch += 1;
+ }
+ }
+
+ /// The error handed to anything waiting on a session that has ended.
+ fn closed(&self) -> Option {
+ self.closed.clone()
+ }
+
+ /// The union's and the limit's patterns for `direction`, each `None` while
+ /// unrestricted.
+ fn parts(&self, direction: Direction) -> (Option<&Patterns>, Option<&Patterns>) {
+ // The peer's grant names what we may do; the limit names what the peer may,
+ // so what we send is what the peer may subscribe to, and the other way around.
+ match direction {
+ Direction::Publish => (
+ self.union.as_ref().map(|union| &union.publish),
+ self.limit.as_ref().map(|limit| &limit.subscribe),
+ ),
+ Direction::Subscribe => (
+ self.union.as_ref().map(|union| &union.subscribe),
+ self.limit.as_ref().map(|limit| &limit.publish),
+ ),
+ }
+ }
+
+ /// What `direction` allows right now.
+ fn permit(&self, direction: Direction) -> Permit {
+ let (granted, limit) = self.parts(direction);
+ Permit {
+ granted: granted.cloned(),
+ limit: limit.cloned(),
+ }
+ }
+
+ /// Whether `direction` allows `path` right now, without copying the patterns.
+ fn allows(&self, direction: Direction, path: &str) -> bool {
+ let (granted, limit) = self.parts(direction);
+ granted.is_none_or(|granted| granted.matches(path)) && limit.is_none_or(|limit| limit.matches(path))
+ }
+}
+
+/// What one direction of a session allows: the grant the peer gave this side (the
+/// union of its tokens) and the limit this side set on the peer, each `None`
+/// while unrestricted.
+#[derive(Clone, Debug, Default, PartialEq, Eq)]
+pub(crate) struct Permit {
+ granted: Option,
+ limit: Option,
+}
+
+impl Permit {
+ /// Whether both allow `path`.
+ pub(crate) fn matches(&self, path: &str) -> bool {
+ self.granted.as_ref().is_none_or(|granted| granted.matches(path)) && self.within_limit(path)
+ }
+
+ /// Whether the limit alone allows `path`: for what the peer offers us, which our
+ /// own grant does not decide.
+ pub(crate) fn within_limit(&self, path: &str) -> bool {
+ self.limit.as_ref().is_none_or(|limit| limit.matches(path))
+ }
+
+ /// Whether this allows everything `other` does, judged part by part, so a `false`
+ /// may be spurious but a `true` never is.
+ pub(crate) fn covers(&self, other: &Self) -> bool {
+ let covers = |this: &Option, other: &Option| match (this, other) {
+ (None, _) => true,
+ (Some(_), None) => false,
+ (Some(this), Some(other)) => this.covers(other),
+ };
+ covers(&self.granted, &other.granted) && covers(&self.limit, &other.limit)
+ }
+}
+
+/// The `AUTHORIZATION TOKEN` this side presents on its own SUBSCRIBE and PUBLISH_NAMESPACE
+/// requests (MoQ request-token), shared between a session's [`Handle`] and its driver.
+///
+/// The session reads the current value when it first sends a request, and re-presents a
+/// changed one on each live request as a REQUEST_UPDATE. The default presents no token,
+/// which is byte-identical to a session that never sets one.
+#[derive(Clone, Default)]
+pub(crate) struct RequestToken {
+ token: kio::Shared