Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
09350f1
quest: start relay in-band AUTH refresh
kixelated Oct 10, 2026
998278d
quest(auth): record relay refresh integration dependencies
kixelated Oct 10, 2026
bcda865
Merge remote-tracking branch 'origin/main' into worktree-agent-aa5183…
kixelated Oct 12, 2026
501988c
feat(relay): renew independent in-band authorization grants
kixelated Oct 11, 2026
31cbda5
fix(relay): follow main's connects counter and timed test tracks
kixelated Oct 12, 2026
f3b5886
Merge origin/main into quest/m1/auth/relay-refresh
kixelated Oct 13, 2026
581b0cc
fix(net): assert TOO_MANY_REQUESTS on pending AUTH overflow
kixelated Oct 13, 2026
0891041
Merge remote-tracking branch 'origin/main' into iterate-5282
kixelated Oct 13, 2026
a93d325
fix(relay): keep renewal query bytes and refuse public-rule tokens as…
kixelated Oct 13, 2026
5d5a0df
Merge origin/main into quest/m1/auth/relay-refresh
kixelated Oct 13, 2026
f6f279b
Merge origin/main into quest/m1/auth/relay-refresh
kixelated Oct 13, 2026
84aeab6
fix(auth): address in-band renewal review findings
kixelated Oct 13, 2026
6b10af6
Merge origin/main into quest/m1/auth/relay-refresh
kixelated Oct 13, 2026
c071570
fix(relay): retry in-band tokens past auth outages, flag attachments
kixelated Oct 13, 2026
a6ffc5c
Merge remote-tracking branch 'origin/main' into iterate-5282
kixelated Oct 13, 2026
4cf426c
fix(auth): validate the owner grant for empty tokens, drain attached …
kixelated Oct 13, 2026
f0ab7e3
Merge remote-tracking branch 'origin/main' into iterate-5282
kixelated Oct 13, 2026
9fc7d24
fix(relay): validate the exact grant snapshot an in-band credential i…
kixelated Oct 13, 2026
0d5966b
Merge remote-tracking branch 'origin/main' into iterate-5282
kixelated Oct 13, 2026
6b5053b
Merge remote-tracking branch 'origin/main' into iterate-5282
kixelated Oct 13, 2026
ff87031
doc(relay): scope the outage retry to moq-lite
kixelated Oct 13, 2026
0fb91bb
Merge remote-tracking branch 'origin/main' into iterate-5282
kixelated Oct 13, 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
4 changes: 4 additions & 0 deletions .github/workflows/nightly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,10 @@ jobs:
if: ${{ !cancelled() }}
run: nix develop --command cargo bench --locked -p moq-relay --bench http_idle -- --test

- name: Relay auth refresh benchmark smoke
if: ${{ !cancelled() }}
run: nix develop --command just rs bench-relay-auth --test

# One relay's stats tick (drain plus plain and compressed encode), swept
# over held paths, tiers, and how many of those paths changed, plus the
# decode table. `--test` runs each tick routine once (decode ignores it
Expand Down
50 changes: 43 additions & 7 deletions doc/bin/relay/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,12 @@ 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
flowing. A wider grant brings those paths back within the admitted root and
SETUP role. A one-shot HTTP `/fetch` ends on a narrower grant instead. A
changed `root`, `mounts`, `peer`, or `upstream`, or a 401 or 403, ends that
credential's grant, which closes the session unless an
[in-band](#in-band-renewal) credential still holds it. A changed `tier` keeps
the session and moves its stats from then on. 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.

Expand Down Expand Up @@ -113,14 +114,47 @@ TOKEN` option with Token Type 0. HMAC, RSA, ECDSA, and EdDSA keys all work. A
key can itself be **scoped** at generation (`--root`, `--publish`,
`--subscribe`), after which it can never sign a broader token.

### In-band renewal

On moq-lite-07-wip or moq-transport draft-17 and later with MoQ Auth negotiated,
a client can present another JWT without reconnecting. Present its replacement
before the current grant expires and keep the returned token handle alive.
The native `Connection::auth()` handle re-presents retained tokens after a
reconnect; the JavaScript session's `auth.add()` presents one for that session.
Older versions refuse in-band tokens.

Each accepted token adds its publish and subscribe patterns to the session's
union, within its admitted root and SETUP role. A publish-only connection cannot
become a subscriber. A replacement must retain the root, mounts, and routing
classification. Dropping a token handle, expiry, or refusal at revalidation
removes that token's grant: streams that lose permission reset, and other grants
keep the connection alive. A refused new token leaves existing grants alone.
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.
Certificate sessions cannot renew a certificate in band, and public rules
cannot verify a presented token; both refuse it as unsupported. A burst of
tokens past the session's pending-verification bound is refused. On moq-lite, an
auth server that cannot answer, by timeout or outage, refuses a token as a
transient error, and `Connection::auth()` presents it again on its reconnect
backoff. On moq-transport the refusal is final and the token ends. Retrying
never extends an accepted grant, so a session whose last grant expires during an
outage still closes.

### Claims

| Claim | Meaning |
| --- | --- |
| `root` | Base path. Optional. |
| `publish` | Patterns the bearer may publish under `root`. `**` means everything; omitted means no publishing. |
| `subscribe` | Patterns the bearer may subscribe to under `root`. Same rules. |
| `exp`, `iat`, `nbf` | Expiry, issue time, and not-before. `exp` is enforced for the whole session, not just at connect, and a token is refused from its `exp` on and before its `nbf`. |
| `exp`, `iat`, `nbf` | Expiry, issue time, and not-before. `exp` ends that credential's grant, including after connect; another accepted token can keep the session alive. A token is refused from its `exp` on and before its `nbf`. |
| `iss`, `sub`, `jti` | Read and ignored. |

Any other claim refuses the token with its name, `aud` included: an unknown
Expand Down Expand Up @@ -296,7 +330,9 @@ admissions itself, without the HTTP: leave `[auth]` empty and take
refusal. A fixed lease never changes; a `lease::Producer` the application keeps
re-checks, updates, and revokes on its own clock. The relay still closes the
session at the grant's `expires`, and an admission left unanswered for ten
seconds is refused. Gateway accept loops can call `Cluster::admit` for the same
seconds is refused. `Admission::attached()` marks a token presented
[in band](#in-band-renewal) for a session already admitted under the same id,
so count sessions only on the others. Gateway accept loops can call `Cluster::admit` for the same
scoped session as a native connection. See
[docs.rs/moq-relay](https://docs.rs/moq-relay) and
[moq-auth](/lib/rs/moq-auth).
Expand Down
6 changes: 5 additions & 1 deletion doc/concept/moq-lite.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,11 @@ 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 token the peer has no verifier for, or
whose grant is too large to send, fails with `Unsupported` and ends only that
token; the session and its other grants stay up.
token; the session and its other grants stay up. A relay with an
[auth server](/bin/relay/auth#in-band-renewal) verifies these tokens through it,
so a fresh grant can keep the connection alive after its original credential
expires; under public rules or a client certificate it refuses them as
`Unsupported`.

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
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.** A `FETCH` returns one group from the cache, or on drafts 14 through 19 the saved prefix of the group a new subscription just joined. A range of groups is refused, as is a draft 20+ `LOCATION_FILTER` that ends at Largest Object rather than an absolute End Location. On draft 20 and later, `FETCH_OK`'s End Location is the requested end, capped at Largest Object: a range holding no objects gets an empty fetch stream, and only a start past Largest Object is refused, with `INVALID_RANGE`. A relay's copy with no upstream subscription can't know Largest Object short of the track's end, so it echoes the requested end instead. JavaScript publishing refuses every `FETCH`. Datagrams are never fetchable. A Rust reader that only fetches learns the track from `TRACK_STATUS` instead of subscribing, so a finished track stays fetchable; draft 17 still subscribes, since its `TRACK_STATUS` answer cannot say whether the track is timed.
- **Timing.** A track whose `SUBSCRIBE_OK` declares no `TIMESCALE` is untimed, as is every track on drafts 14 through 16. A Rust reader that never subscribed takes the units from `TRACK_STATUS` on draft 18 and later.
- **Strict SETUP and parameters.** Repeated unknown `SETUP` options, GREASE included, are accepted if well-formed; a repeated known option is rejected. On draft 17 and later, a `GROUP_ORDER` other than Ascending (1) or Descending (2) closes the session. Message parameters follow the negotiated draft's lists: drafts 14 and 15 ignore an unknown or misplaced parameter, draft 16 ignores one defined only for another message but closes the session on an unknown one, and draft 17 and later close it on both.
- **One credential per session**, carried in `SETUP` and forwarded to the [auth server](/bin/relay/auth#the-contract) unverified. A second 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`. A token attached to an individual request is otherwise decoded and grants nothing, and a request repeating one is refused with `MALFORMED_AUTH_TOKEN`. `SUBSCRIBE_TRACKS` is refused unread, so its token is not decoded.
- **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`. A token attached to an individual request is otherwise decoded and grants nothing, and a request repeating one is refused with `MALFORMED_AUTH_TOKEN`. `SUBSCRIBE_TRACKS` is refused unread, so 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`, range filters, `TRACK_STATUS` to a JavaScript publisher, `SUBSCRIBE_TRACKS`, and the fetch forms above. On draft 19 and later, and in Rust on drafts 14 through 16, a subscription update may change only priority; any other update ends that subscription.
- **Datagrams** are a single normal object at object 0, forwarded without renumbering. Anything else is dropped, and a malformed one closes the session. Rust and JavaScript both carry them on every draft.
- **Priority.** Higher is served first. The IETF default of 128 is this stack's 127, and a track that never sets one is 127.
Expand Down
67 changes: 67 additions & 0 deletions js/net/src/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,46 @@ async function connect(opts: { publish?: OriginProducer; serverPublish?: OriginP

// 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("pending application tokens have a per-session bound", async () => {
const { client, server } = await connect({ publish: new OriginProducer(), protocol });
try {
const requests = server.auth.requests();
const setup = await requests.next();
if (!setup) throw new Error("setup token missing");
setup.accept(grant(["a"], []));
let refused!: () => void;
const overflow = new Promise<void>((resolve) => {
refused = resolve;
});
const pending = Array.from({ length: 129 }, (_, index) =>
client.auth.add(`token-${index}`).then(
(token) => ({ token }),
(err: unknown) => {
refused();
return { err };
},
),
);
await withTimeout(overflow, 1000, "pending token bound did not refuse overflow");
for (let index = 0; index < 128; index++) {
const request = await requests.next();
if (!request) throw new Error("pending token missing");
request.accept(grant(["a"], []));
}
const results = await Promise.all(pending);
const errors = results.flatMap((result) => ("err" in result ? [result.err] : []));
expect(errors).toHaveLength(1);
// moq-transport's AUTH_ERROR registry only distinguishes unsupported from unauthorized.
expect((errors[0] as SessionError).code).toBe(
protocol === Lite.ALPN_07_WIP ? SessionCode.TooManyRequests : SessionCode.Unauthorized,
);
for (const result of results) if ("token" in result) result.token.close();
} finally {
client.abort();
server.abort();
}
});

test("both sides learn their default grant", async () => {
const { client, server } = await connect({ publish: new OriginProducer(), protocol });

Expand Down Expand Up @@ -449,6 +489,33 @@ test("a revoked grant resets its subscriptions with UNAUTHORIZED", async () => {
}
});

test("withdrawn queued tokens are pruned before the next application decision", async () => {
const { client, server, transport } = await connect({ publish: new OriginProducer(), protocol: Lite.ALPN_07_WIP });
try {
const requests = server.auth.requests();
const setup = await requests.next();
if (!setup) throw new Error("setup token missing");
setup.accept(grant(["a"], []));
const wire = new LiteAuthWire(transport, Lite.Version.DRAFT_07);
for (let index = 0; index < 128; index++) {
const stream = await wire.present(new TextEncoder().encode("withdrawn"));
await stream.writer.close();
await stream.reader.closed;
}
const live = client.auth.add("live");
live.catch(() => void 0);
const request = await requests.next();
if (!request) throw new Error("live token missing");
expect(new TextDecoder().decode(request.token)).toBe("live");
request.accept(grant(["a"], []));
(await live).close();
await request.closed;
} finally {
client.abort();
server.abort();
}
});

// Capture the acceptor's actual AUTH writer after a valid setup grant. The following
// bytes travel through the same connection dispatcher and presenter as a real peer.
describe.each([Lite.ALPN_07_WIP, Ietf.ALPN.DRAFT_17, Ietf.ALPN.DRAFT_22])("AUTH violations %s", (protocol) => {
Expand Down
2 changes: 2 additions & 0 deletions js/net/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ export interface Token {
export interface Request {
/** The token the peer presented. Empty means the credential its connection carried. */
readonly token: Uint8Array;
/** Settles when the peer withdraws this unanswered token or closes the session. */
readonly closed: PromiseLike<Error | null>;
/**
* Grant the token until the returned {@link Issued} is revoked or closed.
*
Expand Down
33 changes: 27 additions & 6 deletions js/net/src/auth_session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,14 @@ class PeerRequest implements Request {
this.#issued = issued;
}

get closed(): Promise<Error | null> {
return this.#issued.closed;
}

get ended(): boolean {
return this.#issued.ended;
}

accept(grant: Grant): Issued {
if (this.#answered) throw new Error("already answered");
this.#answered = true;
Expand All @@ -144,6 +152,7 @@ class PeerRequest implements Request {

/** Our side of one of the peer's tokens: the grant we issued and its stream. */
class IssuedGrant implements Issued {
ended = false;
readonly closed: Promise<Error | null>;
#stream: Stream;
#wire: AuthWire;
Expand All @@ -162,7 +171,10 @@ class IssuedGrant implements Issued {
.then(
() => null,
(err: unknown) => (err instanceof StreamError && err.code === StreamCode.Cancel ? null : error(err)),
);
)
.finally(() => {
this.ended = true;
});
}

#write(write: () => Promise<void>) {
Expand Down Expand Up @@ -204,16 +216,25 @@ class RequestQueue implements Requests {
#waiters: ((request: PeerRequest | undefined) => void)[] = [];
#closed = false;

push(request: PeerRequest): boolean {
if (this.#closed) return false;
/** Queue the token for the application, or refuse it when closed or full. */
push(request: PeerRequest): void {
if (this.#closed) {
request.reject(SessionCode.Unauthorized, "not accepting tokens");
return;
}
if (this.#queue.length >= 128) this.#queue = this.#queue.filter((queued) => !queued.ended);
if (this.#queue.length >= 128) {
request.reject(SessionCode.TooManyRequests, "too many pending tokens");
return;
}
const waiter = this.#waiters.shift();
if (waiter) waiter(request);
else this.#queue.push(request);
return true;
}

next(): Promise<Request | undefined> {
const next = this.#queue.shift();
let next = this.#queue.shift();
while (next?.ended) next = this.#queue.shift();
if (next || this.#closed) return Promise.resolve(next);
return new Promise((resolve) => this.#waiters.push(resolve));
}
Expand Down Expand Up @@ -323,7 +344,7 @@ export class AuthSession implements AuthApi {
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");
this.#acceptor.push(request);
} else if (token.byteLength > 0) {
// Only the connection's own credential has a default answer; a token needs
// someone to verify it.
Expand Down
2 changes: 0 additions & 2 deletions quest/m1/auth/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,6 @@ because `main` assigned 0x3A to NOT_FETCHABLE first.
## Required

- [WebSocket refusal](/quest/m1/auth/ws-unauthorized.md) - the relay serves WebSocket through moq-tokio, so a refused token closes the session as Unauthorized, as QUIC does
- [Relay tokens](/quest/m1/auth/relay-refresh.md) - the relay verifies tokens
sent in band, unions their grants, and cancels only work that loses access
- [Request tokens](/quest/m1/auth/request-token.md) - an experimental Rust
verifier hook confines a token's authority to its request and renews it
- [Request gate](/quest/m1/auth/request-gate.md) - every incoming request is
Expand Down
5 changes: 1 addition & 4 deletions quest/m1/auth/bindings.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,7 @@ export the experimental Rust request hook through the bindings here.
Arc<MoqAuthToken>` whose object carries that token's own `grant()` and
`closed()` and whose release withdraws it, raising the structured error
moq-ffi already maps on refusal. `MoqClient` sessions reached through the
reconnecting connection use the accessor
[Relay tokens](/quest/m1/auth/relay-refresh.md) adds.
reconnecting connection use `moq_tokio::Connection::auth()`.
- No new libmoq API: the hand-written C ABI gets no more feature work. The
generated C and C++ bindings get this from moq-ffi, and OBS reaches it
through the generated C++; update `cpp/obs/src`
Expand All @@ -48,5 +47,3 @@ surface comes from moq-ffi rather than new `moq_session_auth_*` calls.
## Required

- [FFI shape](/quest/m1/ffi-shape/README.md) - reshapes the moq-ffi session and wrappers this edits, so the wrappers break once
- [Relay tokens](/quest/m1/auth/relay-refresh.md) - supplies the connection
accessor and a relay that answers a real token, which the wrapper tests need
8 changes: 5 additions & 3 deletions quest/m1/auth/expired-error.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@ refusal as final.
shared moq-transport session value if one means the same thing, otherwise
0x31.
- Carry it through moq-ffi's error mapping and each wrapper.
- Switch the relay's in-band renewal (`rs/moq-relay/src/auth/refresh.rs`) to
refuse or revoke an expired credential with `Expired`. Today it uses
`UNAUTHORIZED`, with the cause only in the reason.

Moved from m2 into the auth line: relay tokens refuse an expired
token with `AUTH_ERROR { Expired }`, so without this moq-net cannot tell it
apart from `Unauthorized`.
Moved from m2 into the auth line: without this, a client renewing in band
cannot tell an expired token apart from a refused one.

## Required

Expand Down
Loading
Loading