Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
f59cb32
chore(quest): claim quest/m1/ietf-token-auth.md
kixelated Oct 29, 2026
9ca5acb
feat(auth)!: checkpoint shared request-token grants and attachment le…
kixelated Oct 29, 2026
28aed00
Merge remote-tracking branch 'origin/quest/m1/ietf-token-auth' into i…
kixelated Oct 29, 2026
63de0ef
Merge remote-tracking branch 'origin/main' into ietf-token-auth
kixelated Oct 29, 2026
28c9045
feat(auth): bind token updates across request lifetimes
kixelated Oct 29, 2026
c4faddb
Merge remote-tracking branch 'origin/main' into ietf-token-auth
kixelated Oct 31, 2026
9350ae8
quest: finish ietf-token-auth
kixelated Oct 31, 2026
5f0ed21
fix(net): a joining FETCH follows a token its subscription's update n…
kixelated Oct 31, 2026
7accdd2
fix(net): hold a token listing's PUBLISHes to its token
kixelated Oct 31, 2026
0196672
fix(net): a joining FETCH on a token-listed PUBLISH follows the token
kixelated Oct 31, 2026
f986c13
fix(net): check a token-listed PUBLISH's joining FETCH against its track
kixelated Oct 31, 2026
c773e63
perf(net): keep the request-token index out of the session's auth state
kixelated Oct 31, 2026
574da4a
Merge remote-tracking branch 'origin/main' into ietf-token-auth
kixelated Oct 31, 2026
949353a
fix(net): keep request-token observers and refusals consistent
kixelated Oct 31, 2026
47c5515
Merge remote-tracking branch 'origin/main' into merge-7211
kixelated Oct 31, 2026
2a864aa
fix(net): guard SUBSCRIBE_NAMESPACE by a token an update names later
kixelated Oct 31, 2026
dc05707
fix(net): end a listed PUBLISH's grant before answering its refused r…
kixelated Oct 31, 2026
314bd03
Merge branch 'base' into pr-7211
Nov 1, 2026
eb6d3b9
fix(net): follow base sync and Send replacements
Nov 1, 2026
ec83acc
Merge base and reconcile removed publisher tracks decoder
Nov 1, 2026
e7ae6c5
Merge origin/main into quest/m1/ietf-token-auth
kixelated Nov 1, 2026
41f1de5
Merge the base-merge fixer's head, keeping the dead-code and clock-li…
kixelated Nov 1, 2026
04fe1f0
Merge remote-tracking branch 'origin/main' into HEAD
kixelated Nov 1, 2026
8de5a32
fix(moq-net): verify a namespace update's token without stalling its …
kixelated Nov 1, 2026
bca5c01
fix(moq-net): read no namespace update while a token's verdict is pen…
kixelated Nov 1, 2026
c303c72
Merge remote-tracking branch 'origin/main' into quest-iterate/ietf-to…
kixelated Nov 1, 2026
afe4557
Merge remote-tracking branch 'origin/main' into quest-iterate/ietf-to…
kixelated Nov 1, 2026
5aad955
fix(moq-net): refuse a token PUBLISH through main's buffered error path
kixelated Nov 1, 2026
e4f00cd
Merge origin/main into quest/m1/ietf-token-auth
kixelated Nov 1, 2026
2493c47
fix(moq-net): keep a verifying namespace update's stream through a fa…
kixelated Nov 1, 2026
c7d67b1
fix(moq-net): keep a verifying namespace update's stream when its tok…
kixelated Nov 1, 2026
5505939
fix(moq-net): finish a verification drain its request's guard cancelled
kixelated Nov 1, 2026
21615aa
Merge origin/main into quest/m1/ietf-token-auth
kixelated Nov 1, 2026
0f9f853
Merge remote-tracking branch 'origin/main' into iterate-7211
kixelated Nov 1, 2026
9e16bf2
Merge remote-tracking branch 'origin/main' into quest/m1/ietf-token-auth
kixelated Nov 3, 2026
8af7a27
fix(auth): a bound gate verifies a replacement token as a renewal
kixelated Nov 3, 2026
9d48319
Merge remote-tracking branch 'origin/main' into iterate-7211-m6
kixelated Nov 3, 2026
18043d3
Merge remote-tracking branch 'origin/main' into iterate-7211-m6
kixelated Nov 3, 2026
82ca068
feat(auth)!: fix a request's admission, refuse unlimited announces, e…
kixelated Nov 3, 2026
6c10d38
fix(auth): report a PUBLISH_NAMESPACE token's kind as PublishNamespace
kixelated Nov 3, 2026
57ffdd9
Merge remote-tracking branch 'origin/main' into iterate-7211-m6
kixelated Nov 3, 2026
1c8c82f
quest: drop auth-expiry-reset's link to the finished ietf-token-auth
kixelated Nov 3, 2026
1a3ae66
fix(auth): read a pending grant extension before the expiry check
kixelated Nov 3, 2026
3be57ca
Merge remote-tracking branch 'origin/main' into iterate-7211-m6
kixelated Nov 3, 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
31 changes: 19 additions & 12 deletions doc/bin/relay/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ path patterns under a root, and the session can only see that part of the tree.

## The contract

The relay POSTs one JSON body per session event (`connect`, `revalidate`,
The relay POSTs one JSON body per session event (`connect`, `attach`, `revalidate`,
`end`) to `--auth-url` and reads a grant back. The request carries everything
the relay knows: a session id, its own `node` name, the transport, addresses,
SNI and ALPN, the path and query exactly as dialed, a moq-transport SETUP
Expand Down Expand Up @@ -79,7 +79,11 @@ reaches `expires` closes as `Expired` rather than `Unauthorized`, which also
stops the reconnect loop: refresh the token, then reconnect with the new URL.

**End.** Every close reports `end` with the reason, the duration, and the
bytes sent and received. A shutdown waits for these reports before exiting.
bytes sent and received. An event with `auth` names one attached credential;
its `end` releases only that credential. Without `auth`, `end` closes the
connection, after every attachment has reported its own end. A shutdown waits
for these reports before exiting. Auth servers must accept `attach` and handle `auth` before enabling
in-band credentials. An older server refuses `attach` before a credential is admitted.

**The link.** `https://` presents the relay's `connect.tls` client certificate
when one is configured, so a remote server can tell which relay is asking;
Expand Down Expand Up @@ -180,17 +184,20 @@ The grant reports its real expiry so the client can renew in time.

The relay verifies a presented JWT through the same admission path, replacing
`jwt` in the original query and preserving the connection's identity. The auth
server sees another `connect` for the same session id. Attached grants revalidate
independently but never report `end`; only the connection reports its end, even
if its first credential expired earlier, after its attached grants stop. Each
attached JWT counts against its own `--limit-token` cap until the connection
ends. The initial credential remains responsible for the connection's stats tier.
server sees `attach` for the same session id, with a fresh `auth` id
that also names its revalidations and end. Attached grants revalidate and end
independently. Each JWT counts against its own `--limit-token` cap while any
credential using it remains on that connection. The initial credential remains
responsible for the connection's stats tier.
Certificate sessions cannot renew a certificate in band, and public rules
cannot verify a presented token; both refuse it as unsupported. A moq-transport
`AUTHORIZATION TOKEN` on a `SUBSCRIBE` or `PUBLISH_NAMESPACE` is verified the
same way on any draft, but its grant authorizes only that request and ends with
it; it never joins the union. A `PUBLISH_NAMESPACE` grant must cover the whole
namespace: `room/**`, not just `room`. A session verifies at most
cannot verify a presented token; both refuse it as unsupported. On moq-transport,
each distinct `AUTHORIZATION TOKEN` is verified once while any open request on
that session names it. Its grant admits only requests naming that token,
independently of the session's scope. Discovery reveals only paths it grants;
a `PUBLISH_NAMESPACE` grant must cover the whole namespace: `room/**`, not just
`room`. The last naming request ending releases the lease at the auth server;
a later request verifies the token again. Expiry or revocation ends every
request naming it. A joining `FETCH` still follows its subscription. A session verifies at most
128 tokens at once, counting
checks still running past the admission timeout. Later tokens wait their turn
rather than being refused. Each HTTP request is admitted on its own, so an
Expand Down
2 changes: 1 addition & 1 deletion doc/concept/standard.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ subset drops. What a peer actually observes against this implementation:
- **Compression.** Rust peers exchange compressed tracks when both offer the [Flate extension](/draft/moq-flate). Other peers receive one shared inflated copy. A publisher that seals the compression declaration makes that fallback unavailable: an incompatible peer is refused, preserving the sealed metadata. JavaScript does not offer the IETF extension.
- **Timing.** A track whose `SUBSCRIBE_OK` or `PUBLISH` declares no `TIMESCALE` is untimed. A `SUBSCRIBE` or `FETCH` with `INCLUDE_PROPERTIES=0` gets no `TIMESCALE` in its `SUBSCRIBE_OK` or `FETCH_OK`, so its objects go out unstamped. A Rust reader that never subscribed takes the units from `TRACK_STATUS`, and still asks for the properties whenever it subscribes to or fetches a timed track.
- **Strict SETUP and parameters.** Repeated unknown `SETUP` options, GREASE included, are accepted if well-formed; a repeated known option is rejected. A `GROUP_ORDER` other than Ascending (1) or Descending (2) closes the session. An unknown message parameter, or one defined only for another message, closes the session. Other malformed control input closes the session with the draft's code too, such as a server `SETUP` carrying `PATH` or `AUTHORITY`, a namespace field ending in `/` (it would map to a path with an empty segment), 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, and a refused renewal ends only that request. Rust and JavaScript send the token a route names (`Route::auth`) on its `PUBLISH_NAMESPACE`, and swapping it renews the request with `REQUEST_UPDATE`; the inline `NAMESPACE` that answers a solicited `SUBSCRIBE_NAMESPACE` carries no token. A token added to a reconnecting connection's auth handle rides every session it makes, so the announcement keeps it across a reconnect; one added to a single session's handle ends with that session. A token on any other request is decoded and grants nothing, and a request repeating one is refused with `MALFORMED_AUTH_TOKEN`. 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, each distinct request token is verified once while any open request on that session names it, and its grant authorizes only requests naming it, 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 on a token-authorized request moves it to the token it carries; the old token ends with its last naming request. A request is admitted by the session or by a token for good, so an update naming a token on one the session admitted is refused with `NOT_SUPPORTED`. A token-less `PUBLISH_NAMESPACE` our limit does not cover is refused `UNAUTHORIZED`, and one the limit stops covering ends; the publisher re-announces once it widens. Expiry or revocation ends every request naming that token. The relay reports the lease end to its auth server, and a later request naming the token verifies it again. A refused renewal ends only that request. Rust and JavaScript send the token a route names (`Route::auth`) on its `PUBLISH_NAMESPACE`, and swapping it renews the request with `REQUEST_UPDATE`; the inline `NAMESPACE` that answers a solicited `SUBSCRIBE_NAMESPACE` carries no token. A token added to a reconnecting connection's auth handle rides every session it makes, so the announcement keeps it across a reconnect; one added to a single session's handle ends with that session. Rust honors a token on any supported request; discovery reveals only paths it grants, and a joining `FETCH` keeps following its subscription. A request repeating a token is refused with `MALFORMED_AUTH_TOKEN`. The negotiated [MoQ Auth extension](/concept/moq-lite#authorization) accepts additional session tokens in band.
- **Pause delivery.** `FORWARD=0` on a `SUBSCRIBE` or its update drops the subscription, closing its data streams and holding nothing upstream, while the request and its slot stay. `FORWARD=1` subscribes again as a new `SUBSCRIBE` with the same filter would, so nothing carries across the pause: its `REQUEST_OK` names the live edge it joined at. A paused update may change only priority and max age.
- **Refused, not fatal.** A legal request this stack does not serve is rejected on its own and the session stays up: `SUBSCRIBE_TRACKS`, multi-group `FETCH`, and joining requests reaching before the joined group get `NOT_SUPPORTED`. Range Filters get `INVALID_FILTER` from draft 20 because the advertised filter limit is zero. From draft 20, 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, negotiated subscriber max age, and delivery timeouts. Rust also carries range changes, keeps bounded subscriptions updatable, renews request tokens, and judges `NEW_GROUP_REQUEST` as a join (see dynamic groups below). From draft 20, a wider or historical fill fails on its separate fetch stream while its subscription stays open; a largest-group fill hands its head to the live subgroup tail. 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`. 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, including across a moq-lite-07 hop between two relays that negotiate the [ietf](/draft/moq-ietf) extension. Datagrams retain their IDs, properties, priority, and status. Plain Rust readers and moq-lite peers without the extension see consecutive frames and only normal datagram payloads at Object ID 0; other datagram objects and statuses are dropped on those surfaces. 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, as for a plain frame; an immutable one that would change or vanish is refused. On a timed track the frame holds each object's time, so a mutable Timestamp that only repeats the immutable one is not restated; a status object has no frame, so it keeps its Timestamp among its properties. Only property 0x10 is read as a Timestamp. Property 0x06 stays opaque and keeps its value regardless of timestamp units; it is dropped between draft 18 and later drafts, or refused when immutable, because draft 20 assigns it to SUBGROUP\_DELIVERY\_TIMEOUT. A status object carrying properties is refused. A `FETCH` has no status objects, so its 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.
Expand Down
13 changes: 13 additions & 0 deletions drafts/draft-lcurley-moq-auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,17 @@ When the union shrinks, the presenter SHOULD withdraw its advertisements and can
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.

## Request Tokens

An acceptor that verifies {{moqt}} AUTHORIZATION TOKEN request parameters treats each distinct token on a session as one implicit Auth request while any open request names it.
Its grant MUST NOT join the session scope: a naming request uses that grant alone, and a request without the token gains nothing from it.
The acceptor verifies the token when its first naming request opens and ends its grant when the last naming request ends; a later request verifies it again.
Expiry or revocation ends every naming request.
A request is admitted either by the session or by a token, and that never changes.
A REQUEST_UPDATE carrying a token replaces the token authority of a request a token admitted, including when the update pauses forwarding; the acceptor MUST verify the token before acknowledging the update.
On a request the session admitted, the acceptor MUST refuse an update carrying a token.
A joining FETCH retains its subscription's authority.

## Patterns {#patterns}

A grant names Path Patterns of {{moq-lite}}, with the same grammar, canonical form, and encoding as its AUTH_OK.
Expand Down Expand Up @@ -240,6 +251,8 @@ This document requests three registrations in the "MOQT Message Types" registry

## Since draft-lcurley-moq-auth-00 (in progress)

- Request tokens share verification and lifetime per distinct token, isolated from session scope; updates replace a token-admitted request's token, even while pausing or resuming, and a token on a session-admitted request's update is refused.

- AUTH_ERROR can carry GOING_AWAY, refusing an AUTH past the acceptor's GOAWAY without refusing the token.
- AUTH_OK carries moq-lite Path Patterns instead of Track Namespace prefixes, and a grant is refused only when too large for one AUTH_OK.
- The AUTH Setup Option moves from 0x40B60 to 0x40B70.
Expand Down
42 changes: 42 additions & 0 deletions js/auth/api/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -674,6 +674,7 @@ export type Request = infer<typeof RequestSchema>;

export const RequestSchema: ZodMiniDiscriminatedUnion<[ZodMiniObject<{
alpn: ZodMiniOptional<ZodMiniString<string>>;
auth: ZodMiniOptional<ZodMiniString<string>>;
event: ZodMiniLiteral<"connect">;
id: ZodMiniString<string>;
implementation: ZodMiniOptional<ZodMiniString<string>>;
Expand Down Expand Up @@ -712,6 +713,46 @@ export const RequestSchema: ZodMiniDiscriminatedUnion<[ZodMiniObject<{
}>>>;
}, $strip>, ZodMiniObject<{
alpn: ZodMiniOptional<ZodMiniString<string>>;
auth: ZodMiniString<string>;
event: ZodMiniLiteral<"attach">;
id: ZodMiniString<string>;
implementation: ZodMiniOptional<ZodMiniString<string>>;
local: ZodMiniOptional<ZodMiniString<string>>;
node: ZodMiniString<string>;
path: ZodMiniString<string>;
query: ZodMiniOptional<ZodMiniString<string>>;
remote: ZodMiniOptional<ZodMiniString<string>>;
role: ZodMiniOptional<ZodMiniEnum<{
publisher: "publisher";
subscriber: "subscriber";
}>>;
server_name: ZodMiniOptional<ZodMiniString<string>>;
tls: ZodMiniOptional<ZodMiniObject<{
expires: ZodMiniOptional<ZodMiniInt>;
fingerprint: ZodMiniString<string>;
issuer: ZodMiniString<string>;
name: ZodMiniString<string>;
}, $strip>>;
token: ZodMiniOptional<ZodMiniObject<{
kind: ZodMiniInt;
value: ZodMiniString<string>;
}, $strip>>;
transport: ZodMiniPipe<ZodMiniString<string>, ZodMiniCatch<ZodMiniEnum<{
http: "http";
iroh: "iroh";
quic: "quic";
rtmp: "rtmp";
srt: "srt";
tcp: "tcp";
unix: "unix";
unknown: "unknown";
webrtc: "webrtc";
websocket: "websocket";
webtransport: "webtransport";
}>>>;
}, $strip>, ZodMiniObject<{
alpn: ZodMiniOptional<ZodMiniString<string>>;
auth: ZodMiniOptional<ZodMiniString<string>>;
event: ZodMiniLiteral<"revalidate">;
id: ZodMiniString<string>;
implementation: ZodMiniOptional<ZodMiniString<string>>;
Expand Down Expand Up @@ -750,6 +791,7 @@ export const RequestSchema: ZodMiniDiscriminatedUnion<[ZodMiniObject<{
}>>>;
}, $strip>, ZodMiniObject<{
alpn: ZodMiniOptional<ZodMiniString<string>>;
auth: ZodMiniOptional<ZodMiniString<string>>;
bytes: ZodMiniObject<{
received: ZodMiniInt;
sent: ZodMiniInt;
Expand Down
17 changes: 17 additions & 0 deletions js/auth/src/contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,23 @@ test("an end request carries its facts beside the rest", () => {
expect(invalid.reason).toBe("invalid");
});

test("an attachment has its own identity through its end", () => {
const base = { id: "connection", node: "relay", transport: "quic", path: "/room" };
expect(() => RequestSchema.parse({ ...base, event: "attach" })).toThrow();
const attach = RequestSchema.parse({ ...base, event: "attach", auth: "credential" });
const end = RequestSchema.parse({
...base,
event: "end",
auth: "credential",
reason: "dropped",
duration: 1,
bytes: { sent: 0, received: 0 },
});
expect(attach.auth).toBe("credential");
expect(end.auth).toBe(attach.auth);
expect(end.id).toBe(attach.id);
});

test("a SETUP token parses as the Rust vector", () => {
// What `moq_auth::Request` serializes a CAT of bytes 00 fb ff to.
const request = RequestSchema.parse(
Expand Down
8 changes: 8 additions & 0 deletions js/auth/src/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ export const BytesSchema = z.object({
export type Bytes = z.infer<typeof BytesSchema>;

const BaseRequestSchema = z.object({
/** The attached credential this event concerns; absent for the connection itself. */
auth: z.optional(z.string()),
/** Random 128-bit hex, unique per session; the key every event for it shares. */
id: z.string(),
/** The operator's name for the relay asking. */
Expand Down Expand Up @@ -142,6 +144,12 @@ export const RequestSchema = z.discriminatedUnion("event", [
/** A session was accepted and asks to be admitted. */
event: z.literal("connect"),
}),
z.extend(BaseRequestSchema, {
/** Another credential asks to join the connection. */
event: z.literal("attach"),
/** The credential's own lifecycle identity. */
auth: z.string(),
}),
z.extend(BaseRequestSchema, {
/** The grant asked to be re-checked on its cadence. */
event: z.literal("revalidate"),
Expand Down
Loading
Loading