Skip to content
Merged
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
4 changes: 3 additions & 1 deletion doc/concept/moq-lite.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,9 @@ 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.
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 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
8 changes: 6 additions & 2 deletions drafts/draft-lcurley-moq-lite.md
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,8 @@ Sent when terminating the session, via the transport's session close.
| ------- | ------------- | ----------- |
| 0x17 | UNKNOWN_AUTH_TOKEN_ALIAS | A token named an alias that was never registered. |
| ------- | ------------- | ----------- |
| 0x30 | NOT_SUPPORTED | The requested feature, token, or grant is unsupported. |
| ------- | ------------- | ----------- |

### Stream Error Codes
Sent when resetting a stream (RESET_STREAM), or when refusing to receive one (STOP_SENDING).
Expand Down Expand Up @@ -550,8 +552,9 @@ When the union shrinks, an endpoint SHOULD withdraw its announcements and cancel
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.
An acceptor that does not verify a token in band, or cannot express its grant in AUTH_OK, MUST send AUTH_ERROR with NOT_SUPPORTED and close its side of the stream.
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)).

### Goaway
Either endpoint can open a Goaway Stream (0x5) to initiate a graceful session shutdown.
Expand Down Expand Up @@ -1563,6 +1566,7 @@ The `Message Length` describes the payload size on the wire.
- Added the AUTH_TOKEN_CACHE_OVERFLOW (0x13) and UNKNOWN_AUTH_TOKEN_ALIAS (0x17) session codes, with moq-transport's meaning.
- Limited AUTH_OK Expires to 2^53 - 1 milliseconds, with an out-of-range value closing the session with PROTOCOL_VIOLATION.
- Invalid UTF-8 or an oversized AUTH_ERROR reason closes the session with PROTOCOL_VIOLATION.
- Added NOT_SUPPORTED (0x30) to session errors; unsupported Auth Stream tokens and grants use AUTH_ERROR before or after a grant.
- 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
49 changes: 49 additions & 0 deletions js/net/src/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,35 @@ describe.each([Lite.ALPN_07_WIP, Ietf.ALPN.DRAFT_17, Ietf.ALPN.DRAFT_22])("%s",
server.abort();
});

test("an oversized grant update reports Unsupported and revokes only its token", async () => {
const { client, server } = await connect({ publish: new OriginProducer(), protocol });
const requests = server.auth.requests();
const issued: Issued[] = [];
const answering = (async () => {
for (;;) {
const request = await requests.next();
if (!request) break;
issued.push(request.accept(grant([request.token.byteLength === 0 ? "a" : "b"], [])));
}
})();
try {
await waitFor(client.auth.grant, (g) => g?.publish.equals(patterns("a")) === true);
const token = await client.auth.add("update");
await waitFor(client.auth.grant, (g) => g?.publish.equals(patterns("a", "b")) === true);
const publish = new Path.Patterns(
Array.from({ length: 20 }, (_, i) => Path.Pattern.subtree(`${i}${"x".repeat(4000)}`)),
);
issued[issued.length - 1].update({ publish, subscribe: patterns() });
expect(await token.closed).toBeInstanceOf(Unsupported);
expect(token.grant.peek()).toBeUndefined();
await waitFor(client.auth.grant, (g) => g?.publish.equals(patterns("a")) === true);
} finally {
client.abort();
server.abort();
await answering;
}
});

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 });
Expand Down Expand Up @@ -150,6 +179,26 @@ describe.each([Lite.ALPN_07_WIP, Ietf.ALPN.DRAFT_17, Ietf.ALPN.DRAFT_22])("%s",
server.abort();
});

test("an explicit unsupported token uses the negotiated AUTH registry", async () => {
const { client, server } = await connect({ publish: new OriginProducer(), protocol });
const requests = server.auth.requests();
const answering = (async () => {
for (;;) {
const request = await requests.next();
if (!request) break;
if (request.token.byteLength === 0) request.accept(grant([], []));
else request.reject(SessionCode.Unsupported, "credential kind unsupported");
}
})();
try {
await expect(client.auth.add("unsupported")).rejects.toBeInstanceOf(Unsupported);
} finally {
client.abort();
server.abort();
await answering;
}
});

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();
Expand Down
4 changes: 3 additions & 1 deletion js/net/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
*/

import { type Getter, Signal } from "@moq/signals";
import type { SessionCode } from "./error.ts";
import { SessionCode } from "./error.ts";
import type * as Path from "./path.ts";

/**
Expand All @@ -38,6 +38,8 @@ export function grantsEqual(a: Grant | undefined, b: Grant | undefined): boolean

/** The session or peer carries no AUTH exchange, or takes no tokens in band. */
export class Unsupported extends Error {
/** The moq-lite session code for this condition. */
readonly code = SessionCode.Unsupported;
constructor(message = "auth unsupported") {
super(message);
this.name = "Unsupported";
Expand Down
10 changes: 8 additions & 2 deletions js/net/src/error.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,10 +221,16 @@ test("the code tables match the spec", () => {
expect(Number(StreamCode.SessionClosed)).toBe(0x3);
expect(Number(StreamCode.TooFarBehind)).toBe(0x5);

// The session table carries only what the draft assigns.
// Session codes retain moq-transport assignments below 32; Lite owns 48-63.
for (const code of Object.values(SessionCode)) {
expect(code).toBeLessThan(32);
if (code === SessionCode.Unsupported) {
expect(code).toBeGreaterThanOrEqual(48);
expect(code).toBeLessThan(64);
} else {
expect(code).toBeLessThan(32);
}
}
expect(Number(SessionCode.Unsupported)).toBe(0x30);

// The stream table adds moq-lite's own 48-63 assignments. 32-47 is reserved and
// carries nothing, so no code sits there.
Expand Down
2 changes: 2 additions & 0 deletions js/net/src/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ export const SessionCode = Object.freeze(
Version: 0x15 as SessionCode,
/** A token named an alias that was never registered. */
UnknownAuthTokenAlias: 0x17 as SessionCode,
/** The peer cannot support the requested feature; moq-lite only. */
Unsupported: 0x30 as SessionCode,
} as const),
);

Expand Down
4 changes: 2 additions & 2 deletions js/net/src/ietf/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,8 +262,8 @@ export class IetfAuthWire implements AuthWire {
}

async refuse(stream: Stream, code: SessionCode, reason: string): Promise<void> {
// The public API speaks session codes; this registry distinguishes only a version mismatch.
const wire = code === SessionCode.Version ? NOT_SUPPORTED : UNAUTHORIZED;
// The public API speaks session codes; this registry distinguishes only unsupported from unauthorized.
const wire = code === SessionCode.Version || code === SessionCode.Unsupported ? NOT_SUPPORTED : UNAUTHORIZED;
await new AuthError(wire, reason).encode(stream.writer, this.#version);
}

Expand Down
25 changes: 24 additions & 1 deletion js/net/src/lite/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ test("AUTH_OK matches the Rust encoding", async () => {
});

// 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.
// refuse it, so the acceptor withholds it, type included, before sending AUTH_ERROR.
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) => {
Expand Down Expand Up @@ -119,6 +119,29 @@ test.each([
await expect(roundTrip((w) => new AuthMessage(new Uint8Array()).encode(w, version))).rejects.toThrow();
});

test("unsupported after AUTH_OK is an AUTH_ERROR on the wire", async () => {
const chunks: Uint8Array[] = [];
const stream = new Stream({
readable: new ReadableStream(),
writable: new WritableStream<Uint8Array>({
write: (chunk) => {
chunks.push(new Uint8Array(chunk));
},
}),
version: Lite.Version.DRAFT_07,
});
const wire = new LiteAuthWire({} as WebTransport, Lite.Version.DRAFT_07);
await wire.grant(stream, { publish: patterns("room/**"), subscribe: patterns() });
await wire.unsupported(stream, "grant cannot be encoded");
stream.close();
await stream.writer.closed;
const reader = new Reader(undefined, new Uint8Array(Buffer.concat(chunks)), Lite.Version.DRAFT_07);
expect(await decodeAuthReplyMaybe(reader, Lite.Version.DRAFT_07)).toBeInstanceOf(AuthOk);
const reply = await decodeAuthReplyMaybe(reader, Lite.Version.DRAFT_07);
expect(reply).toBeInstanceOf(AuthError);
expect((reply as AuthError).code).toBe(0x30);
});

test("malformed AUTH_OK closes its session", async () => {
for (const pattern of ["*/**", "/room", Array(33).fill("room").join("/"), undefined]) {
const reader = await roundTrip(async (w) => {
Expand Down
12 changes: 9 additions & 3 deletions js/net/src/lite/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,12 @@ export class LiteAuthWire implements AuthWire {
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 }) };
return {
refused:
reply.code === SessionCode.Unsupported
? new Unsupported(reply.reason)
: new SessionError(reply.code as SessionCode, { reason: reply.reason }),
};
}

/** The stream type is already consumed by the dispatcher. */
Expand All @@ -223,8 +228,9 @@ export class LiteAuthWire implements AuthWire {
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. */
/** Tell the presenter explicitly, including after an earlier grant. */
async unsupported(stream: Stream, reason: string): Promise<void> {
stream.writer.reset(new Unsupported(reason));
await new AuthError(SessionCode.Unsupported, reason).encode(stream.writer, this.#version);
await stream.writer.close();
}
}
3 changes: 2 additions & 1 deletion quest/m1/auth/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,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
- [Lite NOT_SUPPORTED](/quest/m1/auth/not-supported.md) - a lite acceptor answers AUTH_ERROR NOT_SUPPORTED after a grant too, with a lite session code for `Error::Unsupported`
- [AUTH violations](/quest/m1/auth/violations.md) - every AUTH protocol violation closes the session in Rust and JS, lite and IETF
- [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
Expand Down Expand Up @@ -131,3 +130,5 @@ because `main` assigned 0x3A to NOT_FETCHABLE first.

- [Peer grants](/quest/m3/p2p/peer-grant.md) - P2P's node-bound credential,
built on this line's relay tokens

- [#5269](https://github.com/moq-dev/moq/pull/5269) - explicit Lite AUTH NOT_SUPPORTED, including grant updates
2 changes: 1 addition & 1 deletion quest/m1/auth/expired-error.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ refusal as final.
on `main`. Map it from lite's `AUTH_ERROR { Expired }` and moq-transport's
`EXPIRED_AUTH_TOKEN`, and back again when refusing.
- Lite session code 0x30 is NOT_SUPPORTED
([Lite NOT_SUPPORTED](/quest/m1/auth/not-supported.md)). `Expired` takes a
([Lite NOT_SUPPORTED](https://github.com/moq-dev/moq/pull/5269)). `Expired` takes a
shared moq-transport session value if one means the same thing, otherwise
0x31.
- Carry it through moq-ffi's error mapping and each wrapper.
Expand Down
52 changes: 0 additions & 52 deletions quest/m1/auth/not-supported.md

This file was deleted.

39 changes: 35 additions & 4 deletions rs/moq-net/src/coding/codes.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
//! Which stream reset registry a stream's codes are written and read with.
//! The negotiated registries for errors reported by a stream.

use crate::{Error, StreamError, ietf, lite};

Expand All @@ -20,14 +20,18 @@ pub trait StreamCodes {
/// Read a code the peer reset a stream (or sent STOP_SENDING) with.
fn decode_stream_code(&self, code: u32) -> StreamError;

/// Read the session close reported while using this stream's protocol.
fn decode_session_code(&self, code: u32) -> crate::SessionError {
crate::SessionError::from_code(code)
}

/// Turn a transport failure into an [`Error`], reading a stream reset with this
/// registry.
///
/// A session close is not stream-scoped, so it decodes through
/// [`SessionError`](crate::SessionError) exactly as [`Error::from_transport`] does.
/// Session closes use the same negotiated protocol as stream resets.
fn transport_error<E: crate::transport::Error>(&self, err: E) -> Error {
if let Some((code, _reason)) = err.session_error() {
return crate::SessionError::from_code(code).into();
return self.decode_session_code(code).into();
}

if let Some(code) = err.stream_error() {
Expand All @@ -41,6 +45,10 @@ pub trait StreamCodes {
/// moq-lite's registry, specified by draft-lcurley-moq-lite (Error Codes). A code a version
/// lacks is sent there as the closest one it has, and not read back there.
impl StreamCodes for lite::Version {
fn decode_session_code(&self, code: u32) -> crate::SessionError {
lite::error::from_session_code(code)
}

fn encode_stream_code(&self, err: &StreamError) -> u32 {
match err {
StreamError::NotFetchable if !self.has_not_fetchable() => StreamError::NotFound.to_code(),
Expand Down Expand Up @@ -70,6 +78,13 @@ impl StreamCodes for ietf::Version {
/// The negotiated version before it is narrowed to one protocol, e.g. the SETUP stream a
/// pre-lite-05 session opens.
impl StreamCodes for crate::Version {
fn decode_session_code(&self, code: u32) -> crate::SessionError {
match self {
Self::Lite(version) => version.decode_session_code(code),
Self::Ietf(version) => version.decode_session_code(code),
}
}

fn encode_stream_code(&self, err: &StreamError) -> u32 {
match self {
Self::Lite(version) => version.encode_stream_code(err),
Expand Down Expand Up @@ -196,6 +211,22 @@ mod tests {
Error::Stream(StreamError::Internal)
));

// A session-level failure still needs the stream's negotiated protocol.
assert!(matches!(
version.transport_error(Failed {
session: Some(0x30),
stream: None
}),
Error::Session(crate::SessionError::Unknown(0x30))
));
assert!(matches!(
lite::Version::Lite07.transport_error(Failed {
session: Some(0x30),
stream: None
}),
Error::Session(crate::SessionError::Unsupported)
));

// Neither: the transport itself failed.
assert!(matches!(
version.transport_error(Failed {
Expand Down
Loading
Loading