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..c503679d30 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.
diff --git a/doc/concept/moq-lite.md b/doc/concept/moq-lite.md
index e9c9d4894a..743272c3b6 100644
--- a/doc/concept/moq-lite.md
+++ b/doc/concept/moq-lite.md
@@ -141,6 +141,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..071a330b6d 100644
--- a/doc/concept/standard.md
+++ b/doc/concept/standard.md
@@ -39,6 +39,7 @@ 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.
diff --git a/doc/lib/js/net.md b/doc/lib/js/net.md
index e3c41b51c2..4286e8dc09 100644
--- a/doc/lib/js/net.md
+++ b/doc/lib/js/net.md
@@ -43,6 +43,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 e28a622d05..c4ead51ffd 100644
--- a/doc/lib/rs/moq-net.md
+++ b/doc/lib/rs/moq-net.md
@@ -22,6 +22,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 dc963c4150..cd636a8f27 100644
--- a/drafts/draft-lcurley-moq-lite.md
+++ b/drafts/draft-lcurley-moq-lite.md
@@ -334,6 +334,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 +366,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.
@@ -524,6 +528,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.
@@ -1365,6 +1388,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.
@@ -1448,6 +1549,7 @@ 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 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
@@ -1463,6 +1565,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.
@@ -1615,6 +1718,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..92e836ffbf 100644
--- a/js/net/src/connection/accept.ts
+++ b/js/net/src/connection/accept.ts
@@ -39,6 +39,8 @@ export interface AcceptProps {
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;
};
/**
@@ -67,6 +69,7 @@ async function acceptInner(
const wiring: SessionProps = {
discovery: props.discovery ?? true,
publish: props.publish,
+ client: false,
};
if (protocol === Ietf.ALPN.DRAFT_22) {
@@ -115,7 +118,7 @@ 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");
return new Ietf.Connection({
...wiring,
@@ -127,6 +130,7 @@ async function acceptAlpn(
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/connect.ts b/js/net/src/connection/connect.ts
index 980fbf40de..a681d42a93 100644
--- a/js/net/src/connection/connect.ts
+++ b/js/net/src/connection/connect.ts
@@ -122,6 +122,8 @@ 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;
};
// Save if WebSocket won the last race, so we won't give QUIC a head start next time.
@@ -167,6 +169,7 @@ async function connectInner(url: URL, props: Omit, abort: P
const wiring: SessionProps = {
discovery: props.discovery ?? true,
publish: props.publish,
+ client: true,
};
if (props.transport) {
@@ -361,7 +364,7 @@ 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");
return new Ietf.Connection({
...wiring,
@@ -373,6 +376,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..41ff35bfb3 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-07-wip, and on moq-transport draft-17+ when both sides negotiate
+ * MoQ Auth, each side presents its connection's credential right after setup.
+ * Otherwise the grant stays undefined.
+ */
+ 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/handshake.ts b/js/net/src/connection/handshake.ts
index 81bb2052a9..04da453389 100644
--- a/js/net/src/connection/handshake.ts
+++ b/js/net/src/connection/handshake.ts
@@ -12,7 +12,8 @@ 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
+ * extension), its Hop ID (see the MoQ Cluster extension), and whether it offered MoQ Auth.
+ * We declare all three 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.
@@ -27,12 +28,14 @@ export async function exchangeSetup(
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);
Ietf.hiddenIntoSetup(params);
+ Ietf.Auth.intoSetup(params, version);
// One id per session, like the moq-lite connection: nothing in this process forwards
// between sessions, so there is nothing for a shared id to detect.
@@ -52,6 +55,7 @@ export async function exchangeSetup(
solicit: received.solicit,
hidden: received.hidden,
cluster: { self, peer: received.cluster },
+ auth: received.auth,
};
}
@@ -79,6 +83,7 @@ async function receiveSetup(
solicit: boolean | undefined;
hidden: boolean;
cluster: Hop | undefined;
+ auth: boolean;
}> {
const uniReader = transport.incomingUnidirectionalStreams.getReader() as ReadableStreamDefaultReader<
ReadableStream
@@ -120,5 +125,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/error.test.ts b/js/net/src/error.test.ts
index 210d39efa7..c6d5756c2a 100644
--- a/js/net/src/error.test.ts
+++ b/js/net/src/error.test.ts
@@ -230,6 +230,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 +249,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..c72b5236df 100644
--- a/js/net/src/error.ts
+++ b/js/net/src/error.ts
@@ -96,6 +96,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. */
@@ -370,6 +372,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 +475,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/connection.ts b/js/net/src/ietf/connection.ts
index 6cf0994973..37beef5614 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 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.
@@ -106,6 +118,7 @@ export class Connection implements Established {
solicit,
hidden = false,
cluster,
+ auth = false,
early = [],
requestWindow,
}: {
@@ -131,6 +144,8 @@ export class Connection implements Established {
* cannot negotiate the extension, as is a `peer` the peer never declared.
*/
cluster?: Cluster.Hops;
+ /** Whether the peer's SETUP offered MoQ Auth (draft-17+). */
+ 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`). */
@@ -165,6 +180,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,6 +206,8 @@ export class Connection implements Established {
requiresSolicitation: solicit ?? false,
hidden,
cluster,
+ grant: this.#auth.grant,
+ ready: this.#auth.setupAnswered(),
});
this.#solicit = solicit;
this.#cluster = cluster;
@@ -182,6 +218,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 +252,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);
@@ -237,12 +276,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);
@@ -384,6 +427,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..7ec969bbb4 100644
--- a/js/net/src/ietf/parameters.ts
+++ b/js/net/src/ietf/parameters.ts
@@ -20,6 +20,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).
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.ts b/js/net/src/ietf/publisher.ts
index 247e088e3d..265c2d38da 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/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/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 53c52a5eae..d658c44dc7 100644
--- a/js/net/src/lite/announce.ts
+++ b/js/net/src/lite/announce.ts
@@ -514,7 +514,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..51b96cec6e 100644
--- a/js/net/src/lite/connection.ts
+++ b/js/net/src/lite/connection.ts
@@ -1,5 +1,7 @@
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";
@@ -7,11 +9,12 @@ import { type Transport, transportOf } from "../connection/transport.ts";
import { closeError, error, fromClose, ProtocolViolation, SessionCode, 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.
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 634e7ca98d..a34ee6283e 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) {
@@ -1728,6 +1732,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 e408b8918c..55014421be 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";
@@ -393,6 +395,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
@@ -407,11 +418,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);
@@ -518,24 +539,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);
}
@@ -567,22 +620,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 of active.keys()) {
if (!updated.has(suffix)) await retract(suffix);
@@ -618,6 +666,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 =
@@ -628,6 +720,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}`));
@@ -643,6 +737,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 } });
@@ -742,6 +837,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 {
@@ -758,6 +881,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.
@@ -779,6 +904,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,
@@ -1033,6 +1164,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(
@@ -1051,6 +1194,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();
@@ -1059,9 +1203,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
@@ -1389,6 +1541,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 ac308cda3a..148133462f 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";
@@ -1067,6 +1068,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 cb6b9c603a..5b4b5704a7 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";
@@ -79,6 +90,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;
+}
+
/**
* What a consumed broadcast captured when it was opened: the epoch it asks for, and its own id.
* Fetches are shared only within one consume, so a fresh consume for a replaced instance never
@@ -128,20 +151,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;
@@ -183,6 +192,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
@@ -191,6 +205,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
*/
@@ -201,6 +216,7 @@ export class Subscriber {
probe?: Signal,
peerSetup?: Signal,
goaway?: GetPromise,
+ grant?: Getter,
) {
this.#quic = quic;
this.version = version;
@@ -208,6 +224,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
@@ -222,6 +239,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.
@@ -599,6 +622,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.
@@ -622,9 +676,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 };
@@ -637,8 +688,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);
@@ -729,7 +782,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,
@@ -771,6 +824,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 e608e7de42..2d492e38f1 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 fa41d74eaf..c867be64a1 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-token decode](/quest/m1/auth/request-token-decode.md) - an
diff --git a/quest/m1/auth/relay-refresh.md b/quest/m1/auth/relay-refresh.md
index 00812358dc..f9bba19257 100644
--- a/quest/m1/auth/relay-refresh.md
+++ b/quest/m1/auth/relay-refresh.md
@@ -68,6 +68,12 @@ expiry, an expiry that leaves the union intact ends only that token, and
the live session and forgotten. Its request-token half belongs to
[Request tokens](/quest/m1/auth/request-token.md); whichever lands first
adds the handle.
+- Bound pending tokens: `auth::Handle::requests()` hands out an unbounded
+ queue today, and a closed AUTH stream leaves its request queued, so a peer
+ that finishes AUTH streams faster than the relay verifies them grows it
+ without limit (Codex security review on #4039). This quest is the first
+ consumer, so bound it per session here and refuse what overflows; dropping a
+ `Request` already refuses its token.
- Docs: `doc/bin/relay/auth.md` gains an "in-band tokens" section beside
revalidation stating that grants union, that a token needs the admitted
root, what an expiry does to the union today, and that the grant's expiry
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/quest/m1/quest-flat-lines.md b/quest/m1/quest-flat-lines.md
index 7ae9d002e5..3a983933d7 100644
--- a/quest/m1/quest-flat-lines.md
+++ b/quest/m1/quest-flat-lines.md
@@ -59,11 +59,8 @@ Remaining:
Landed so far: #4403 wildcard, #4034 archive (with #4255 folded in), #4080
obs-moq-video, #4162 audio-jitter-target, #4180 transport-upgrade, #4437
rs2ts (with #4438 folded in), #4640 tstd, #4653 test-flakes-2, #4079 cpp,
- and #4519 ffi-shape. As of the 2026-10-10 audit, only #4039 auth remains
- open. Its `wip-version.md` child landed on the line (#5004). The line's
- UNAUTHORIZED stream code collides with `main`'s NOT_FETCHABLE (0x3A) and
- moves to 0x3B before it lands. Reconcile its remaining code and quests with
- `main` before landing it.
+ #4519 ffi-shape, and #4039 auth (UNAUTHORIZED moved to 0x3B). No umbrella
+ PR remains open.
- #4133 qos closed without landing (2026-10-09). Its egress `lag` histogram
is owned by [Viewer lag histogram](/quest/m1/qos/lag-histogram.md), which
ports it once stats-split lands. Do not reopen the umbrella PR.
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..db5028d076
--- /dev/null
+++ b/rs/moq-net/src/auth.rs
@@ -0,0 +1,1100 @@
+//! 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,
+ /// 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 session's auth handle, returned by [`Session::auth`](crate::Session::auth).
+///
+/// Cheap to clone; every clone shares the session's tokens.
+#[derive(Clone)]
+pub struct Handle {
+ state: kio::Shared,
+}
+
+impl Handle {
+ /// A handle for a session that speaks AUTH (`supported`) or never will.
+ pub(crate) fn new(supported: bool) -> Self {
+ Self {
+ state: kio::Shared::new(State {
+ supported,
+ ..Default::default()
+ }),
+ }
+ }
+
+ /// The union of every grant this side holds: `None` until the peer first
+ /// answers a token (with a grant or a refusal), and forever on a version
+ /// without AUTH.
+ pub fn grant(&self) -> Watch {
+ Watch::new(self.state.clone(), Selector::Union)
+ }
+
+ /// Present another token, resolving once the peer answers it.
+ ///
+ /// The grant joins the union for as long as the returned [`Token`] lives.
+ ///
+ /// # Errors
+ ///
+ /// [`Error::Unsupported`] when the version or the peer carries no AUTH (or the
+ /// peer takes no tokens in band), the peer's refusal as
+ /// [`Error::Session`], or the session's close.
+ pub async fn add(&self, token: impl Into) -> Result {
+ let token = self.present(token.into(), false)?;
+ kio::wait(|waiter| token.poll_answered(waiter)).await?;
+ Ok(token)
+ }
+
+ /// Queue a token for the driver to present.
+ pub(crate) fn present(&self, token: Bytes, setup: bool) -> Result {
+ let mut state = self.state.lock();
+ if !state.supported {
+ return Err(Error::Unsupported);
+ }
+ if let Some(err) = state.closed() {
+ return Err(err);
+ }
+ let id = state.next_id;
+ state.next_id += 1;
+ state.tokens.insert(
+ id,
+ Slot {
+ token,
+ setup,
+ grant: None,
+ answered: None,
+ ended: None,
+ withdrawn: false,
+ },
+ );
+ state.opening.push_back(id);
+ Ok(Token {
+ state: self.state.clone(),
+ id,
+ })
+ }
+
+ /// Answer every token the peer presents, instead of the default acceptor.
+ ///
+ /// Call it before running the session's driver: whoever answers is decided
+ /// once, on the driver's first poll, so a later call cannot take over tokens
+ /// already in flight. Without it the session grants the peer what its own
+ /// origin handles allow for the connection's credential, and refuses any other
+ /// token as unsupported.
+ ///
+ /// # Errors
+ ///
+ /// [`Error::Duplicate`] if the requests were already taken, or the driver
+ /// already started answering them itself.
+ pub fn requests(&self) -> Result {
+ let mut state = self.state.lock();
+ if !matches!(state.acceptor, Acceptor::Undecided) {
+ return Err(Error::Duplicate);
+ }
+ let queue = kio::Queue::new();
+ // A version without AUTH never receives a token, so its requests end with
+ // the session like any other.
+ if !state.supported {
+ queue.close();
+ }
+ state.acceptor = Acceptor::App(queue.clone());
+ Ok(Requests { queue })
+ }
+
+ /// Decide who answers the peer's tokens: the app if it took the requests,
+ /// otherwise the session itself (`None`). Idempotent.
+ pub(crate) fn acceptor(&self) -> Option> {
+ let mut state = self.state.lock();
+ match &state.acceptor {
+ Acceptor::App(queue) => Some(queue.clone()),
+ Acceptor::Default => None,
+ Acceptor::Undecided => {
+ state.acceptor = Acceptor::Default;
+ None
+ }
+ }
+ }
+
+ /// The next token to open a stream for, or `Ready(None)` once the session ends.
+ pub(crate) fn poll_opening(&self, waiter: &kio::Waiter) -> Poll