Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
14 changes: 9 additions & 5 deletions doc/concept/moq-lite.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,9 +179,12 @@ 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 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. A relay with an
union of every open token's grant. An announcement whose route names a token
(`Route::auth`) is instead held to that token's grant alone and ends with it;
naming a new token and dropping the old one renews it without a gap. 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. 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
Expand All @@ -197,8 +200,9 @@ to namespace prefixes. Older versions have no grant; the URL token keeps working
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.
reconnect the application adds it again and re-announces. Subscriptions do not
name a token yet, so they ride the session's scope, and a JavaScript acceptor
holds every request to its origin scopes rather than the named token's grant.

## Subscriptions

Expand Down
49 changes: 45 additions & 4 deletions drafts/draft-lcurley-moq-lite.md
Original file line number Diff line number Diff line change
Expand Up @@ -565,7 +565,8 @@ Each endpoint SHOULD open one Auth Stream with an empty token right after the se
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.
The opener withdraws a token by closing its side of the stream: the grant leaves the union at once, and the acceptor closes its own side once no request names the token (see [Named Requests](#named-requests)).
Resetting the stream ends the token at once, with every request naming it.

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.
Expand All @@ -577,6 +578,21 @@ An acceptor that does not verify a token in band, or cannot express its grant in
This applies before or after an AUTH_OK, including when a grant exceeds the message size limit.
A presenter receiving a stream reset before any reply treats it as unsupported, for compatibility with peers that do not support the Auth Stream (see [STREAM_TYPE](#stream_type)).

#### Named Requests {#named-requests}
SUBSCRIBE, SUBSCRIBE_UPDATE, FETCH, ANNOUNCE_REQUEST, ANNOUNCE_START, ANNOUNCE_UPDATE, and ANNOUNCE_RESTART carry an `Auth` field: 0 for the sender's scope, or the [Auth ID](#auth-message) of one of the sender's Auth Streams plus 1.
A named request is held to that token's grant alone instead of the scope: the subscribe patterns for a subscriber's request, the publish patterns for an announcement.
The receiver:

- holds a request naming an Auth ID it has not received, or not yet answered, until it has;
- refuses a request naming a token that has ended or been withdrawn with UNAUTHORIZED;
- ends a request with UNAUTHORIZED, or withholds an announcement, once its token ends or its grant stops covering it.

A named ANNOUNCE_REQUEST receives only the routes its token's grant covers.
An announcement outside its token's grant is withheld rather than closing the session.
SUBSCRIBE_UPDATE, ANNOUNCE_UPDATE, and ANNOUNCE_RESTART replace the request's `Auth`; the previous token keeps deciding the request until the new one is answered.
A withdrawn token keeps deciding the requests that already name it, so an opener renews a token by presenting the new one, naming it, and then withdrawing the old one.
An announcement ends with the token it names: once that token ends, the sender retracts it rather than moving it to its scope (`Auth` 0), and announces it again only under another token.

### Goaway
Either endpoint can open a Goaway Stream (0x5) to initiate a graceful session shutdown.

Expand Down Expand Up @@ -920,6 +936,7 @@ ANNOUNCE_REQUEST Message {
Message Length (i)
Broadcast Path Prefix (s),
Hidden (8),
Auth (i),
}
~~~

Expand All @@ -930,6 +947,9 @@ Indicate interest for any broadcasts with a path that starts with this prefix.
1 to also receive hidden routes (see [Hidden Paths](#hidden)), 0 otherwise.
Any other value is a PROTOCOL_VIOLATION.

**Auth**:
The Auth Stream whose grant admits the request (see [Named Requests](#named-requests)).

The publisher MUST respond with an ANNOUNCE_OK message followed by ANNOUNCE_START messages for any matching routes, followed by ANNOUNCE_START, ANNOUNCE_END, ANNOUNCE_UPDATE, and ANNOUNCE_RESTART messages for any future updates, subject to [Routing](#routing).
Implementations SHOULD consider reasonable limits on the number of matching broadcasts to prevent resource exhaustion.

Expand Down Expand Up @@ -976,6 +996,7 @@ ANNOUNCE_START Message {
Epoch (b),
Hops (..),
Route Cost (i),
Auth (i),
}

Hops {
Expand Down Expand Up @@ -1032,6 +1053,9 @@ A peer whose wire carries no route cost contributes 0 before the arriving link's

lite-06 carries Warm and Cold Route Cost fields. A receiver MUST read Warm as Route Cost and ignore Cold. A sender MUST write Route Cost as Warm and 2^62-1 as Cold.

**Auth**:
The Auth Stream whose grant admits the route (see [Named Requests](#named-requests)).


## ANNOUNCE_END {#announce-end}
A publisher sends an ANNOUNCE_END message to retract a previously started route, referencing its Announce ID.
Expand Down Expand Up @@ -1069,6 +1093,7 @@ ANNOUNCE_UPDATE Message {
Announce ID (i),
Hops (..),
Route Cost (i),
Auth (i),
}
~~~

Expand All @@ -1079,10 +1104,10 @@ Set to 0x2 to indicate an ANNOUNCE_UPDATE message.
The ordinal implicitly assigned by a prior ANNOUNCE_START on this stream.
Referencing an id that was never assigned, or one already retired by an ANNOUNCE_END, is a protocol violation.

**Hops** and **Route Cost**:
**Hops**, **Route Cost**, and **Auth**:
As defined for [ANNOUNCE_START](#announce-start).
`Hop Base` may name this advertisement itself, which resolves against the list being replaced.
An update whose only change is a Route Cost is valid: it is how a relay re-prices a route without disturbing it.
An update whose only change is a Route Cost or Auth is valid: it is how a relay re-prices a route, or a publisher renews its token, without disturbing it.


## ANNOUNCE_RESTART {#announce-restart}
Expand All @@ -1098,6 +1123,7 @@ ANNOUNCE_RESTART Message {
Epoch (b),
Hops (..),
Route Cost (i),
Auth (i),
}
~~~

Expand All @@ -1107,7 +1133,7 @@ Set to 0x3 to indicate an ANNOUNCE_RESTART message.
**Announce ID**:
As defined for [ANNOUNCE_UPDATE](#announce-update).

**Epoch**, **Hops**, and **Route Cost**:
**Epoch**, **Hops**, **Route Cost**, and **Auth**:
As defined for [ANNOUNCE_START](#announce-start), replacing the advertisement's.
`Hop Base` may name this advertisement itself, which resolves against the list being replaced.

Expand All @@ -1129,6 +1155,7 @@ SUBSCRIBE Message {
Group End (i)
Frame Start (i)
Frame End (i)
Auth (i)
}
~~~

Expand Down Expand Up @@ -1183,6 +1210,9 @@ MUST be 0 when `Group End` is 0, since an unbounded subscription has no end grou

`Group End` and `Frame End` are offset by 1 so 0 can mean "absent". `Group Start` is an absolute sequence, and 0 is group 0.

**Auth**:
The Auth Stream whose grant admits the subscription (see [Named Requests](#named-requests)).

## SUBSCRIBE_UPDATE
A subscriber can modify a subscription with a SUBSCRIBE_UPDATE message.
A subscriber MAY send multiple SUBSCRIBE_UPDATE messages to update the subscription.
Expand All @@ -1198,6 +1228,7 @@ SUBSCRIBE_UPDATE Message {
Group End (i)
Frame Start (i)
Frame End (i)
Auth (i)
}
~~~

Expand Down Expand Up @@ -1356,6 +1387,7 @@ FETCH Message {
Group Sequence (i)
Frame Start (i)
Frame End (i)
Auth (i)
}
~~~

Expand Down Expand Up @@ -1385,6 +1417,9 @@ The last frame to return (inclusive), encoded as the absolute frame index + 1.
A value of 0 means through the end of the group (default).
A `Frame End` below `Frame Start` once decoded is a protocol violation; equal bounds are a legal single-frame range.

**Auth**:
The Auth Stream whose grant admits the fetch (see [Named Requests](#named-requests)).

The publisher responds with FRAME messages directly on the same stream — there is no response header.
The subscriber parses them using the track's [TRACK_INFO](#track-info), which it MUST already have (see the [Track Stream](#track-stream)); the group sequence and the index of the first frame are implicit from the FETCH request.
The publisher FINs the stream after the last frame, or resets on error.
Expand Down Expand Up @@ -1450,10 +1485,15 @@ AUTH is the first message on an Auth Stream, presenting a token.
~~~
AUTH Message {
Message Length (i)
Auth ID (i)
Token (b)
}
~~~

**Auth ID**:
The opener's number for the token, which its requests name: 0 for the first Auth Stream it opens on the session, and one more for each after.
A receiver MUST close the session with PROTOCOL_VIOLATION on an Auth ID it already received, or on 2^64-1, which no request can name.

**Token**:
The credential to verify, opaque to moq-lite.
An empty token presents the credential the connection already carried, or nothing.
Expand Down Expand Up @@ -1634,6 +1674,7 @@ The `Message Length` describes the payload size on the wire.
- Any Setup Stream or bidirectional stream message that fails to decode closes the session with PROTOCOL_VIOLATION; previously only an unexpected Message Length did, as a SHOULD. A stream ending mid-message outside the Setup and Auth Streams, a malformed GROUP or FRAME, and a stream type the negotiated version predates still affect only that stream.
- A Report-level publisher reads and ignores PROBE targets instead of resetting the Probe Stream.
- Added NOT_SUPPORTED (0x30) to session errors; unsupported Auth Stream tokens and grants use AUTH_ERROR before or after a grant.
- AUTH carries an Auth ID, numbered from 0 per opener. SUBSCRIBE, SUBSCRIBE_UPDATE, FETCH, ANNOUNCE_REQUEST, ANNOUNCE_START, ANNOUNCE_UPDATE, and ANNOUNCE_RESTART carry an `Auth` field naming one, which holds the request to that token's grant instead of the scope. Closing an Auth Stream withdraws its token while named requests linger on it; a reset ends them.
- 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
Expand Down
18 changes: 12 additions & 6 deletions js/net/src/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,11 @@ import { Version as IetfVersion } from "./ietf/version.ts";
import { AuthMessage as LiteAuthMessage, LiteAuthWire } from "./lite/auth.ts";
import * as Lite from "./lite/index.ts";
import * as LiteMessage from "./lite/message.ts";
import { StreamId as LiteStreamId } from "./lite/stream.ts";
import { createMockTransportPair, type MockTransport } from "./mock.ts";
import { Producer as OriginProducer } from "./origin.ts";
import * as Path from "./path.ts";
import { type Stream, Writer } from "./stream.ts";
import { Stream, Writer } from "./stream.ts";
import { withTimeout } from "./util/timeout.ts";
import { nextTask } from "./util/yield.ts";
import { wireOf } from "./wire.ts";
Expand Down Expand Up @@ -529,9 +530,14 @@ test("withdrawn queued tokens are pruned before the next application decision",
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);
// Numbered past the client's own, which a reused ID would close the session over.
for (let index = 0; index < 3; index++) {
const stream = await wire.present(new TextEncoder().encode("withdrawn"));
const stream = await Stream.open(transport, { version: Lite.Version.DRAFT_07 });
await stream.writer.u53(LiteStreamId.Auth);
await new LiteAuthMessage(BigInt(100 + index), new TextEncoder().encode("withdrawn")).encode(
stream.writer,
Lite.Version.DRAFT_07,
);
await stream.writer.close();
await stream.reader.closed;
}
Expand Down Expand Up @@ -560,9 +566,9 @@ describe.each([Lite.ALPN_07_WIP, Ietf.ALPN.DRAFT_17, Ietf.ALPN.DRAFT_22])("AUTH
this: LiteAuthWire & IetfAuthWire,
token,
) {
const stream = await original.call(this, token);
streams.push(stream);
return stream;
const presented = await original.call(this, token);
streams.push(presented.stream);
return presented;
});
let pair: Pair | undefined;
try {
Expand Down
69 changes: 50 additions & 19 deletions js/net/src/auth_session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,10 @@ export type WireReply = { grant: WireGrant } | { refused: Error };
* @internal
*/
export interface AuthWire {
/** Open a stream and present the token on it. */
present(token: Uint8Array): Promise<Stream>;
/** Open a stream and present the token on it, with the ID a moq-lite request names it by. */
present(token: Uint8Array): Promise<{ stream: Stream; id?: bigint }>;
/** Withdraw a presented token, whose stream then ends once the acceptor lets go. */
withdraw(stream: Stream): void;
/** The next reply on a presented token's stream, or undefined once the acceptor finished it. */
read(stream: Stream): Promise<WireReply | undefined>;
/** The token the peer presents on a stream it opened. */
Expand Down Expand Up @@ -64,10 +66,6 @@ function transportClosed(err: unknown): boolean {
return typeof err === "object" && err !== null && "source" in err && err.source === "session";
}

function cancel(): StreamError {
return new StreamError(StreamCode.Cancel, { message: "cancel" });
}

/** One token this side presented. */
class Presented implements Auth {
readonly grant = new Signal<Grant | undefined>(undefined);
Expand All @@ -77,17 +75,21 @@ class Presented implements Auth {
readonly token: Uint8Array;

stream?: Stream;
/** The ID a moq-lite request names it by, once its stream opened. */
id?: bigint;
withdrawn = false;
isAnswered = false;
ended = false;

#close!: (err: Error | null) => void;
#answer!: () => void;
#refuse!: (err: Error) => void;
#withdraw: (token: Presented) => void;

constructor(token: Uint8Array, setup: boolean) {
constructor(token: Uint8Array, setup: boolean, withdraw: (token: Presented) => void) {
this.token = token;
this.setup = setup;
this.#withdraw = withdraw;
this.closed = new Promise((resolve) => {
this.#close = resolve;
});
Expand Down Expand Up @@ -119,10 +121,7 @@ class Presented implements Auth {
close() {
if (this.withdrawn) return;
this.withdrawn = true;
// The stream's loop notices and ends the token; one still opening checks on arrival.
// One without a stream to come (carried only by requests) ends here.
if (this.stream) this.stream.abort(cancel());
else if (this.isAnswered) this.end(null);
this.#withdraw(this);
}
}

Expand Down Expand Up @@ -336,7 +335,7 @@ export class AuthSession implements Handle {
const bytes = typeof token === "string" ? new TextEncoder().encode(token) : token.slice();
if (!this.#wire) {
// Only a request carries it, and the peer judges it there: no grant to wait for.
const presented = new Presented(bytes, false);
const presented = new Presented(bytes, false, (token) => this.#withdraw(token));
presented.answer();
this.#track(presented);
return presented;
Expand All @@ -356,7 +355,21 @@ export class AuthSession implements Handle {
return token.token;
}

/** Bumps whenever one of this session's tokens ends, so a request carrying it drops it. */
/**
* The ID a moq-lite request names `token` by: undefined for none or for one another session
* issued, leaving the request under the session's scope. Null once `token` is withdrawn or
* ended: what names it ends with it, rather than falling back to the scope.
*/
named(token: Auth | undefined): bigint | null | undefined {
if (!(token instanceof Presented) || !this.#issued.has(token)) return undefined;
if (token.withdrawn || token.ended) return null;
return token.id;
}

/**
* Bumps whenever one of this session's tokens is withdrawn or ends, so a request carrying
* or naming it drops it.
*/
get ended(): Getter<number> {
return this.#ended;
}
Expand Down Expand Up @@ -424,7 +437,7 @@ export class AuthSession implements Handle {
}

#present(wire: AuthWire, token: Uint8Array, setup: boolean): Presented {
const presented = new Presented(token, setup);
const presented = new Presented(token, setup, (token) => this.#withdraw(token));
this.#track(presented);
if (setup) this.#setupPending.update((n) => n + 1);
void this.#run(wire, presented);
Expand All @@ -441,13 +454,28 @@ export class AuthSession implements Handle {
});
}

/**
* Withdraw `token`: its grant leaves the union at once, while its stream, if any, ends once
* the wire lets it go. One still opening checks on arrival, and one without a stream to
* come (carried only by requests) ends here.
*/
#withdraw(token: Presented) {
token.grant.set(undefined);
this.#recompute();
this.#ended.update((n) => n + 1);
if (token.stream) this.#wire?.withdraw(token.stream);
else if (token.isAnswered) token.end(null);
}

async #run(wire: AuthWire, token: Presented) {
let result: Error | null = null;
let closed = false;
try {
const stream = await wire.present(token.token);
const { stream, id } = await wire.present(token.token);
token.stream = stream;
if (token.withdrawn) throw cancel();
token.id = id;
// Withdrawn while its stream opened: let it go as if it had been open all along.
if (token.withdrawn) wire.withdraw(stream);

for (;;) {
let reply: WireReply | undefined;
Expand All @@ -464,13 +492,16 @@ export class AuthSession implements Handle {
throw new ProtocolViolation("malformed AUTH reply", { cause: err });
}
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();
// The peer ended the grant without revoking it, let a withdrawn one go, or
// closed without ever answering. Close our side too, which is what settles
// its Issued.closed.
result = token.isAnswered || token.withdrawn ? null : new Unsupported();
stream.close();
break;
}
if ("grant" in reply) {
// A withdrawn token's grant stays out of the union while it lingers.
if (token.withdrawn) continue;
const expires = reply.grant.expires === undefined ? undefined : wall() + reply.grant.expires;
token.grant.set({ publish: reply.grant.publish, subscribe: reply.grant.subscribe, expires });
this.#replied = true;
Expand Down
Loading
Loading