Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
741e1f7
quest: claim live request auth updates
kixelated Oct 16, 2026
709c663
Merge remote-tracking branch 'origin/main' into quest/m1/auth/request…
kixelated Oct 16, 2026
9ab68fd
quest: record the auth::Auth decision for request updates
kixelated Oct 16, 2026
859c1bd
quest: settle request-update auth binding and scope
kixelated Oct 16, 2026
d0d9335
feat(net)!: a route carries an auth and renews its announce on a swap
kixelated Oct 16, 2026
bae9b79
fix(net): keep the session when a renewal meets a cancelled announce
kixelated Oct 16, 2026
e6d6529
Merge remote-tracking branch 'origin/main' into quest/m1/auth/request…
kixelated Oct 16, 2026
66fd66c
feat(js)!: a route carries its token on moq-transport announcements
kixelated Oct 16, 2026
55102a5
Merge remote-tracking branch 'origin/main' into quest/m1/auth/request…
kixelated Oct 16, 2026
e903aa2
fix(net): an accepted renewal outlives the grant it replaces
kixelated Oct 16, 2026
1cd7272
feat(js)!: mirror Rust's auth names
kixelated Oct 16, 2026
f8a5540
Merge remote-tracking branch 'origin/main' into pr-5695
kixelated Oct 17, 2026
07a5a94
fix(net): settle the last renewal and token-lifecycle findings
kixelated Oct 17, 2026
b7750f6
fix(net): a route auth that ends wakes its request
kixelated Oct 17, 2026
802124f
fix(js): wake the solicited legacy loop when a token ends
kixelated Oct 17, 2026
a5e7886
Merge remote-tracking branch 'origin/main' into pr-5695
kixelated Oct 17, 2026
df28f6b
test(js): build the solicited token test on NativeSession directly
kixelated Oct 17, 2026
2f5ea61
Merge remote-tracking branch 'origin/main' into pr-5695
kixelated Oct 17, 2026
f3c8495
doc(net): drafts 14 and 15 carry a route's token on solicited answers
kixelated Oct 17, 2026
8982b91
fix(net): a reset renewal ends only its request
kixelated Oct 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion doc/concept/moq-lite.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,11 @@ credential expires closes with `EXPIRED_AUTH_TOKEN` instead: refresh the token
and reconnect, rather than treating the credential as invalid. 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.
everywhere. On moq-transport an added token is kept even without the extension,
so an announcement can name it: it rides that `PUBLISH_NAMESPACE` as its
authorization token. The token belongs to the session that added it, so after a
reconnect the application adds it again and re-announces. moq-lite has no
per-request token yet, so there an announcement rides the session's scope.

## Subscriptions

Expand Down
2 changes: 1 addition & 1 deletion doc/concept/standard.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ subset drops. What a peer actually observes against this implementation:
- **History is one group.** In Rust, Object ID gaps must be marked by Prior Object ID Gap properties for a group to be fetchable. A `FETCH` returns one group from the cache, or on drafts 14 through 19 the saved prefix of the group a new subscription just joined. A range of groups is refused, as is a draft 20+ `LOCATION_FILTER` that ends at Largest Object rather than an absolute End Location. On draft 20 and later, `FETCH_OK`'s End Location is the requested end, capped at Largest Object: a range holding no objects gets an empty fetch stream, and only a start past Largest Object is refused, with `INVALID_RANGE`. A relay's copy with no upstream subscription can't know Largest Object short of the track's end, so it echoes the requested end instead. JavaScript publishing refuses every `FETCH`. Datagrams are never fetchable. A Rust reader that only fetches learns the track from `TRACK_STATUS` instead of subscribing, so a finished track stays fetchable; draft 17 still subscribes, since its `TRACK_STATUS` answer cannot say whether the track is timed, and so does a reader whose publisher refuses `TRACK_STATUS` as `NOT_SUPPORTED`.
- **Timing.** A track whose `SUBSCRIBE_OK` declares no `TIMESCALE` is untimed, as is every track on drafts 14 through 16. A Rust reader that never subscribed takes the units from `TRACK_STATUS` on draft 18 and later.
- **Strict SETUP and parameters.** Repeated unknown `SETUP` options, GREASE included, are accepted if well-formed; a repeated known option is rejected. On draft 17 and later, a `GROUP_ORDER` other than Ascending (1) or Descending (2) closes the session. Message parameters follow the negotiated draft's lists: drafts 14 and 15 ignore an unknown or misplaced parameter, draft 16 ignores one defined only for another message but closes the session on an unknown one, and draft 17 and later close it on both. Other malformed control input closes the session with the draft's code too, such as a server `SETUP` carrying `PATH` or `AUTHORITY`, a request cut short by FIN, or a second `GOAWAY` on one stream; a first `GOAWAY` on a request stream is legal.
- **One `SETUP` credential**, forwarded to the [auth server](/bin/relay/auth#the-contract) unverified. A second `SETUP` token closes the session. We advertise no token cache, so an alias reference in `SETUP` closes it with `PROTOCOL_VIOLATION`, one on a request with `UNKNOWN_AUTH_TOKEN_ALIAS`, and a registration on a request with `AUTH_TOKEN_CACHE_OVERFLOW`. Where the Rust side authorizes the peer or verifies tokens, a token on a `SUBSCRIBE` or `PUBLISH_NAMESPACE` is verified like an in-band session token and authorizes that one request alone, even where that authorization already covers it; it never widens the session. It is refused with `NOT_SUPPORTED` when nothing verifies it, when it is empty, or when its Token Type is anything but 0 (out of band). A side that does neither, JavaScript included, ignores the token. Rust refuses a request carrying more than one distinct token with `NOT_SUPPORTED`, since it verifies one; JavaScript, which verifies none, ignores them all. A token-authorized `SUBSCRIBE` still stays within the grant the peer gave us. A token-authorized `PUBLISH_NAMESPACE` needs a grant covering everything beneath the namespace, and the tracks we subscribe under it rely on that announce instead of our limit, ending with it. An update renews the token, except on a drafts 14 and 15 `PUBLISH_NAMESPACE`, which has no update, and a refused renewal ends only that request. A token on any other request is decoded and grants nothing, and a request repeating one is refused with `MALFORMED_AUTH_TOKEN`. `SUBSCRIBE_TRACKS` reads its namespace before refusal; its token is not decoded. On draft 17 and later, the negotiated [MoQ Auth extension](/concept/moq-lite#authorization) accepts additional session tokens in band.
- **One `SETUP` credential**, forwarded to the [auth server](/bin/relay/auth#the-contract) unverified. A second `SETUP` token closes the session. We advertise no token cache, so an alias reference in `SETUP` closes it with `PROTOCOL_VIOLATION`, one on a request with `UNKNOWN_AUTH_TOKEN_ALIAS`, and a registration on a request with `AUTH_TOKEN_CACHE_OVERFLOW`. Where the Rust side authorizes the peer or verifies tokens, a token on a `SUBSCRIBE` or `PUBLISH_NAMESPACE` is verified like an in-band session token and authorizes that one request alone, even where that authorization already covers it; it never widens the session. It is refused with `NOT_SUPPORTED` when nothing verifies it, when it is empty, or when its Token Type is anything but 0 (out of band). A side that does neither, JavaScript included, ignores the token. Rust refuses a request carrying more than one distinct token with `NOT_SUPPORTED`, since it verifies one; JavaScript, which verifies none, ignores them all. A token-authorized `SUBSCRIBE` still stays within the grant the peer gave us. A token-authorized `PUBLISH_NAMESPACE` needs a grant covering everything beneath the namespace, and the tracks we subscribe under it rely on that announce instead of our limit, ending with it. An update renews the token, except on a drafts 14 and 15 `PUBLISH_NAMESPACE`, which has no update, and a refused renewal ends only that request. Rust and JavaScript send the token a route names (`Route::auth`) on its `PUBLISH_NAMESPACE`, including the one drafts 14 and 15 answer a solicited `SUBSCRIBE_NAMESPACE` with, and swapping it renews the request with `REQUEST_UPDATE` from draft 17, or ends and re-sends it before; the inline `NAMESPACE` that answers a solicited `SUBSCRIBE_NAMESPACE` from draft 16 carries no token. The token belongs to the session that added it, so a reconnect means adding it again and re-announcing. A token on any other request is decoded and grants nothing, and a request repeating one is refused with `MALFORMED_AUTH_TOKEN`. `SUBSCRIBE_TRACKS` reads its namespace before refusal; its token is not decoded. On draft 17 and later, the negotiated [MoQ Auth extension](/concept/moq-lite#authorization) accepts additional session tokens in band.
- **Refused, not fatal.** A legal request this stack does not serve is rejected on its own and the session stays up: `FORWARD=0`, `SUBSCRIBE_TRACKS`, and the fetch forms above get `NOT_SUPPORTED`. Range Filters get `INVALID_FILTER` on draft 19 and later because the advertised filter limit is zero. On draft 21 and later, namespaces whose first tuple field is exactly `.` or `.session` get `DOES_NOT_EXIST` before application lookup; other dot prefixes still reach the application. Subscription updates accept priority and delivery timeouts; Rust also renews request tokens and relays `NEW_GROUP_REQUEST` (see dynamic groups below). Other supported-but-unimplemented preferences end that subscription; a `TRACK_PROPERTY_FILTER` there closes the session with `PROTOCOL_VIOLATION`, since it belongs only to `SUBSCRIBE_TRACKS`. On draft 18 and later a namespace update may change its prefix; suffixes after the acknowledgement are relative to that new prefix.
- **Objects and datagrams.** Only subgroup 0 is accepted: a stream for any other subgroup, whether its header gives the ID or takes it from the first Object ID, is stopped and the session stays up. Rust relays preserve Object IDs, gaps, object properties, FIRST\_OBJECT, and END\_OF\_GROUP status objects between moq-transport peers for subgroup 0. Datagrams retain their IDs, properties, priority, and status. Plain Rust readers and moq-lite peers see consecutive frames and normal datagram payloads; JavaScript supports only normal datagrams at Object ID 0. A malformed datagram closes the session; a relayed one the downstream draft cannot carry is dropped. A relayed Timestamp leaves in the units the downstream response declares, and is dropped where it declares none (drafts 14 through 16), as for a plain frame; an immutable one that would change or vanish is refused. Only property 0x10 is read as a Timestamp. Property 0x06 stays opaque and keeps its value regardless of timestamp units; it is dropped across the draft-19 boundary, or refused when immutable, because draft 19 assigns it to SUBGROUP\_DELIVERY\_TIMEOUT. From draft 15, a status object carrying properties is refused, and one relayed from draft 14 leaves without them. Draft 14 allows them, but an END\_OF\_TRACK carrying properties ends the subscription as unsupported, since a track end has nowhere to keep them. A `FETCH` carries the END\_OF\_GROUP object on drafts 14 and 15; later drafts have no FETCH status, so their response ends at the last frame. A group cannot resume across route changes by frame ordinal once Object IDs skip ahead of the ordinals on either route; recovery refuses it instead of splicing different objects.
- **Dynamic groups.** A Rust relay advertises `DYNAMIC_GROUPS` only where the original publisher did, and carries `NEW_GROUP_REQUEST` (draft 15 and later) back to it: on the `SUBSCRIBE` that opens the upstream subscription, or in a `REQUEST_UPDATE` once it is established. Requests from every subscriber of a track coalesce, so one that names an existing group or that an outstanding request already covers goes no further. A request for 0 goes upstream as 0, unless another subscriber's request past the newest group held answers it too. The next group the relay receives answers every request it held, so new groups never send one again. On drafts 15 and 16, whose `TRACK_STATUS` answer has no room for it, a relay copy first learned from `TRACK_STATUS` (a fetch-only reader) does not advertise it to later subscribers. Local publishers never advertise `DYNAMIC_GROUPS`; on their tracks a `SUBSCRIBE`'s request is ignored and an update carrying one ends the subscription with `UPDATE_FAILED`. moq-lite hops do not carry requests yet.
Expand Down
2 changes: 1 addition & 1 deletion doc/lib/js/net.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ broadcast.announce();
- **Timing is per track, with no default.** A track that declares a `timescale` carries a `timestamp` on every frame; one without is [untimed](/concept/moq-lite#subscriptions) and its frames carry none. Omitting it means untimed, as in Rust. Nothing stamps a frame for you: the publisher passes the timestamp, and the `Timed` type (`{ value, at }`) carries one into the `@moq/json` and `@moq/flate` producers.
- **Subscriber staleness is `maxDelay`.** It counts from a group's successor, by media time and by wall clock, whichever reaches it first. Passing the old `maxAge` key throws a `TypeError` naming `maxDelay`. Publisher retention is the separate `Track.Info.maxAge`.
- **Hidden paths** stay out of discovery unless the announce request opts in. See [hidden broadcasts](/concept/moq-lite#hidden-broadcasts).
- **Authorization is in band.** On moq-lite 07 (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.
- **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. On moq-transport, `auth.add(token)` keeps the token even without the extension, and a broadcast announced with the `Auth.Auth` it returns (`broadcast.announce({ auth })`) carries it on that announcement's `PUBLISH_NAMESPACE`; announcing again with another renews the request. An `Auth.Auth` belongs to the session that added it, so after a reconnect, add the token again and re-announce.
- **A graceful close waits.** `await connection.close()` withdraws announcements and drains for up to one second. Application delivery requires explicit `moq-lite-07-wip`; older Lite versions wait only for transport ACKs. IETF media streams are not drained. `abort()` ends immediately.

Examples:
Expand Down
43 changes: 40 additions & 3 deletions js/net/src/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import { describe, expect, spyOn, test } from "bun:test";
import { Milli, Nano } from "@moq/clock";
import type { Getter } from "@moq/signals";
import { type Grant, type Issued, Unsupported } from "./auth.ts";
import type { AuthSession } from "./auth_session.ts";
import { accept as acceptSession, connect as connectSession, type Established } from "./connection/index.ts";
import { SessionCode, SessionError, StreamCode, toStreamCode } from "./error.ts";
import { SessionCode, SessionError, StreamCode, StreamError, toStreamCode } from "./error.ts";
import { AuthMessage as IetfAuthMessage, IetfAuthWire } from "./ietf/auth.ts";
import * as Ietf from "./ietf/index.ts";
import * as IetfMessage from "./ietf/message.ts";
Expand Down Expand Up @@ -367,14 +368,50 @@ test("lite-07 carries literal and wildcard grants exactly", async () => {
server.abort();
});

test.each([Lite.ALPN_05, Lite.ALPN_06, Ietf.ALPN.DRAFT_16])("%s has no grant", async (protocol) => {
// moq-transport keeps the token for the requests that name it, with no grant of its own.
test.each([
[Lite.ALPN_05, false],
[Lite.ALPN_06, false],
[Ietf.ALPN.DRAFT_16, true],
])("%s has no grant, keeping a token for requests: %p", async (protocol, kept) => {
const { client, server } = await connect({ publish: new OriginProducer(), protocol });
expect(client.auth.grant.peek()).toBeUndefined();
await expect(client.auth.add("token")).rejects.toBeInstanceOf(Unsupported);
if (kept) {
const token = await client.auth.add("token");
expect(token.grant.peek()).toBeUndefined();
expect(client.auth.grant.peek()).toBeUndefined();
// A request carries the bytes as added, whatever the caller does with its buffer after.
const bytes = new TextEncoder().encode("bytes");
const copied = await client.auth.add(bytes);
bytes.fill(0);
expect((client.auth as AuthSession).carried(copied)).toEqual(new TextEncoder().encode("bytes"));
// The token ends with its session, like any other.
client.abort();
expect(await token.closed).toBeInstanceOf(Error);
} else {
await expect(client.auth.add("token")).rejects.toBeInstanceOf(Unsupported);
}
client.abort();
server.abort();
});

// With MoQ Auth negotiated, a reset AUTH stream fails the token rather than keeping it for
// requests: only a peer whose SETUP omitted the extension leaves tokens to requests.
test("a reset AUTH stream fails the token on moq-transport", async () => {
const { client, server } = await connect({ publish: new OriginProducer(), protocol: Ietf.ALPN.DRAFT_22 });
await waitFor(client.auth.grant, (g) => g !== undefined);
const read = spyOn(IetfAuthWire.prototype, "read").mockRejectedValue(
new StreamError(StreamCode.Cancel, { message: "reset" }),
);
try {
await expect(client.auth.add("token")).rejects.toBeInstanceOf(Unsupported);
} finally {
read.mockRestore();
client.abort();
server.abort();
}
});

// lite-07 presents the connection's credential on an Auth Stream right away; lite-06 never
// opens one.
test.each([
Expand Down
27 changes: 15 additions & 12 deletions js/net/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@
* 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`.
* undefined. There {@link Handle.add} rejects with {@link Unsupported} on moq-lite, and on
* moq-transport keeps the token, with no grant, for the announcements whose route names it.
* {@link Handle.grant} is the union of every token this side presented, and {@link Handle.add}
* presents another without reconnecting, returning its {@link Auth}. Mirrors the Rust
* `moq_net::auth`.
*
* @module
*/
Expand Down Expand Up @@ -46,13 +48,13 @@ export class Unsupported extends Error {
}
}

/** A token this side presented. {@link Token.close} withdraws it. */
export interface Token {
/** A token this side presented, which a route can name. {@link Auth.close} withdraws it. */
export interface Auth {
/** This token's own grant: undefined until the peer answers, and again once it ends. */
readonly grant: Getter<Grant | undefined>;
/** Settles when the token ends: the peer's revocation, or its withdrawal. */
readonly closed: PromiseLike<Error | null>;
/** Withdraw the token, removing its grant from the union. */
/** Withdraw the token, removing its grant from the union and remaking any request that carries it without it. */
close(): void;
}

Expand Down Expand Up @@ -94,16 +96,17 @@ export interface Requests {
}

/** A session's tokens and grants. See the module docs. */
export interface Auth {
export interface Handle {
/** The union of every grant this side holds: undefined until the peer first answers. */
readonly grant: Getter<Grant | undefined>;

/**
* 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`.
* peer's refusal as an `Error.Session`. On moq-transport without AUTH it resolves at
* once with no grant: only a request whose route names it carries it.
*/
add(token: string | Uint8Array): Promise<Token>;
add(token: string | Uint8Array): Promise<Auth>;

/**
* Answer every token the peer presents, instead of the default: granting what this side
Expand All @@ -118,11 +121,11 @@ export interface Auth {
requests(): Requests;
}

/** The {@link Auth} of a session without AUTH: no grant, and no tokens. */
export class None implements Auth {
/** The {@link Handle} of a session without AUTH: no grant, and no tokens. */
export class None implements Handle {
readonly grant: Getter<Grant | undefined> = new Signal<Grant | undefined>(undefined);

add(_token: string | Uint8Array): Promise<Token> {
add(_token: string | Uint8Array): Promise<Auth> {
return Promise.reject(new Unsupported());
}

Expand Down
Loading
Loading