diff --git a/dart/moq/lib/src/aliases.dart b/dart/moq/lib/src/aliases.dart index 9544a11d095..f3db7414ac9 100644 --- a/dart/moq/lib/src/aliases.dart +++ b/dart/moq/lib/src/aliases.dart @@ -16,6 +16,9 @@ import 'package:moq_ffi/moq_ffi.dart'; /// A MoQ client: configure the TLS/bind knobs, then connect to a relay. typedef Client = MoqClient; +/// The credentials a [Client] presents, replaceable while it runs. +typedef Tokens = MoqTokens; + /// A live pub/sub session with a relay, exposing publish and consume origins. typedef Session = MoqSession; diff --git a/dart/moq/lib/src/client.dart b/dart/moq/lib/src/client.dart index 5eba267d4a5..b876abf0ee7 100644 --- a/dart/moq/lib/src/client.dart +++ b/dart/moq/lib/src/client.dart @@ -25,7 +25,7 @@ final class AnnounceOptions { /// the FFI's [MoqClientConfig]. /// /// Every field is optional; a null keeps the native default. A value the native -/// side cannot use fails [Moq.connect] with `MoqException.Config`. +/// side cannot use fails [Moq.connect] with `ConfigMoqException`. final class ConnectOptions { /// Local socket address to bind, e.g. `0.0.0.0:0`. final String? bind; @@ -34,6 +34,13 @@ final class ConnectOptions { /// Null offers every supported version, which [versions] lists. final List? versions; + /// Credentials every dial presents. The first is the connection credential, + /// sent in SETUP and, while an offered version can't carry it there, the + /// URL's `?jwt=`; each other one widens the session's grant over in-band + /// AUTH. A URL that already carries `?jwt=` keeps it, and then every token + /// rides AUTH. Replace them before they expire through [Moq.tokens]. + final List? tokens; + /// How long one connect may take, dial and handshake together; zero waits forever. final Duration? timeout; @@ -69,6 +76,7 @@ final class ConnectOptions { const ConnectOptions({ this.bind, this.versions, + this.tokens, this.timeout, this.race, this.resolutionDelay, @@ -84,6 +92,7 @@ final class ConnectOptions { MoqClientConfig get _ffi => MoqClientConfig( bind: bind, versions: versions ?? const [], + tokens: tokens ?? const [], timeout: timeout, race: race, resolutionDelay: resolutionDelay, @@ -242,6 +251,17 @@ final class Moq { /// on every reconnect. Auth auth() => session.auth(); + /// The credentials every dial presents ([ConnectOptions.tokens]), to + /// replace while running. + /// + /// [Tokens.set_] replaces them, such as before the current ones expire: the + /// next dial presents them, and a live session with in-band AUTH presents + /// them at once, withdrawing each replaced token once its replacements are + /// answered, even when one is refused. After an authorization refusal the + /// client redials only if they changed. An empty token throws + /// `ConfigMoqException`. + Tokens tokens() => _client.tokens(); + /// Drain within one second, then stop the client. /// /// Explicit `moq-lite-07-wip` waits for the peer to receive finished tails; older diff --git a/dart/moq/lib/src/server.dart b/dart/moq/lib/src/server.dart index 161ed8531ca..aa960f7337a 100644 --- a/dart/moq/lib/src/server.dart +++ b/dart/moq/lib/src/server.dart @@ -7,7 +7,7 @@ import 'version.dart'; /// Everything [Server.listen] can be told. /// /// A value the native side cannot use fails [Server.listen] with -/// `MoqException.Config`. +/// `ConfigMoqException`. final class ListenOptions { /// Local socket address to listen on, e.g. `127.0.0.1:4443` or `[::]:443`. final String bind; diff --git a/dart/moq_ffi/lib/src/moq.dart b/dart/moq_ffi/lib/src/moq.dart index 87e4145ef83..49efb57cee0 100644 --- a/dart/moq_ffi/lib/src/moq.dart +++ b/dart/moq_ffi/lib/src/moq.dart @@ -8,9 +8,7 @@ import "dart:ffi"; import "dart:io" show Platform, File, Directory; import "dart:isolate"; import "dart:typed_data"; - import "package:ffi/ffi.dart"; - import "uniffi_runtime.dart"; export "uniffi_runtime.dart"; @@ -3409,6 +3407,7 @@ class FfiConverterMoqBackoff { class MoqClientConfig { final String? bind; final List versions; + final List tokens; final Duration? timeout; final Duration? race; final Duration? resolutionDelay; @@ -3423,6 +3422,7 @@ class MoqClientConfig { MoqClientConfig({ this.bind = null, this.versions = const [], + this.tokens = const [], this.timeout = null, this.race = null, this.resolutionDelay = null, @@ -3454,6 +3454,11 @@ class FfiConverterMoqClientConfig { ); final versions = versions_lifted.value; new_offset += versions_lifted.bytesRead; + final tokens_lifted = FfiConverterSequenceString.read( + Uint8List.view(buf.buffer, new_offset), + ); + final tokens = tokens_lifted.value; + new_offset += tokens_lifted.bytesRead; final timeout_lifted = FfiConverterOptionalDuration.read( Uint8List.view(buf.buffer, new_offset), ); @@ -3513,6 +3518,7 @@ class FfiConverterMoqClientConfig { MoqClientConfig( bind: bind, versions: versions, + tokens: tokens, timeout: timeout, race: race, resolutionDelay: resolutionDelay, @@ -3533,6 +3539,7 @@ class FfiConverterMoqClientConfig { final total_length = FfiConverterOptionalString.allocationSize(value.bind) + FfiConverterSequenceString.allocationSize(value.versions) + + FfiConverterSequenceString.allocationSize(value.tokens) + FfiConverterOptionalDuration.allocationSize(value.timeout) + FfiConverterOptionalDuration.allocationSize(value.race) + FfiConverterOptionalDuration.allocationSize(value.resolutionDelay) + @@ -3560,6 +3567,10 @@ class FfiConverterMoqClientConfig { value.versions, Uint8List.view(buf.buffer, new_offset), ); + new_offset += FfiConverterSequenceString.write( + value.tokens, + Uint8List.view(buf.buffer, new_offset), + ); new_offset += FfiConverterOptionalDuration.write( value.timeout, Uint8List.view(buf.buffer, new_offset), @@ -3610,6 +3621,7 @@ class FfiConverterMoqClientConfig { static int allocationSize(MoqClientConfig value) { return FfiConverterOptionalString.allocationSize(value.bind) + FfiConverterSequenceString.allocationSize(value.versions) + + FfiConverterSequenceString.allocationSize(value.tokens) + FfiConverterOptionalDuration.allocationSize(value.timeout) + FfiConverterOptionalDuration.allocationSize(value.race) + FfiConverterOptionalDuration.allocationSize(value.resolutionDelay) + @@ -13260,6 +13272,7 @@ class FfiConverterMoqServer { abstract class MoqClientInterface { void cancel(); UniffiRustFuture connect({required String url}); + MoqTokens tokens(); } final _MoqClientFinalizer = Finalizer>((ptr) { @@ -13323,6 +13336,17 @@ class MoqClient implements MoqClientInterface { moqExceptionErrorHandler, ); } + + MoqTokens tokens() { + return rustCallWithLifter( + (status) => uniffi_moq_ffi_fn_method_moqclient_tokens( + uniffiClonePointer(), + status, + ), + FfiConverterMoqTokens.lift, + null, + ); + } } class FfiConverterMoqClient { @@ -13546,6 +13570,78 @@ class FfiConverterMoqSession { } } +abstract class MoqTokensInterface { + void set_({required List tokens}); +} + +final _MoqTokensFinalizer = Finalizer>((ptr) { + rustCall((status) => uniffi_moq_ffi_fn_free_moqtokens(ptr, status)); +}); + +class MoqTokens implements MoqTokensInterface { + late final Pointer _ptr; + bool _isDisposed = false; + MoqTokens._(this._ptr) { + _MoqTokensFinalizer.attach(this, _ptr, detach: this); + } + factory MoqTokens.lift(Pointer ptr) { + return MoqTokens._(ptr); + } + Pointer uniffiClonePointer() { + if (_isDisposed) { + throw StateError("Object has already been disposed"); + } + return rustCall( + (status) => uniffi_moq_ffi_fn_clone_moqtokens(_ptr, status), + ); + } + + void dispose() { + if (_isDisposed) { + return; + } + _isDisposed = true; + _MoqTokensFinalizer.detach(this); + rustCall((status) => uniffi_moq_ffi_fn_free_moqtokens(_ptr, status)); + } + + void set_({required List tokens}) { + return rustCall((status) { + uniffi_moq_ffi_fn_method_moqtokens_set( + uniffiClonePointer(), + FfiConverterSequenceString.lower(tokens), + status, + ); + }, moqExceptionErrorHandler); + } +} + +class FfiConverterMoqTokens { + static MoqTokens lift(Pointer ptr) { + return MoqTokens.lift(ptr); + } + + static Pointer lower(MoqTokens value) { + return value.uniffiClonePointer(); + } + + static int allocationSize(MoqTokens value) { + return 8; + } + + static LiftRetVal read(Uint8List buf) { + final handle = buf.buffer.asByteData(buf.offsetInBytes).getInt64(0); + final pointer = Pointer.fromAddress(handle); + return LiftRetVal(MoqTokens.lift(pointer), 8); + } + + static int write(MoqTokens value, Uint8List buf) { + final handle = lower(value); + buf.buffer.asByteData(buf.offsetInBytes).setInt64(0, handle.address); + return 8; + } +} + abstract class MoqVideoConsumerInterface { void cancel(); UniffiRustFuture next(); @@ -18800,6 +18896,14 @@ external Pointer uniffi_moq_ffi_fn_method_moqclient_connect( RustBuffer url, ); +@Native Function(Pointer, Pointer)>( + assetId: _uniffiAssetId, +) +external Pointer uniffi_moq_ffi_fn_method_moqclient_tokens( + Pointer ptr, + Pointer uniffiStatus, +); + @Native Function(Pointer, Pointer)>( assetId: _uniffiAssetId, ) @@ -18900,6 +19004,31 @@ external RustBuffer uniffi_moq_ffi_fn_method_moqsession_stats( Pointer uniffiStatus, ); +@Native Function(Pointer, Pointer)>( + assetId: _uniffiAssetId, +) +external Pointer uniffi_moq_ffi_fn_clone_moqtokens( + Pointer handle, + Pointer uniffiStatus, +); + +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external void uniffi_moq_ffi_fn_free_moqtokens( + Pointer handle, + Pointer uniffiStatus, +); + +@Native, RustBuffer, Pointer)>( + assetId: _uniffiAssetId, +) +external void uniffi_moq_ffi_fn_method_moqtokens_set( + Pointer ptr, + RustBuffer tokens, + Pointer uniffiStatus, +); + @Native Function(Pointer, Pointer)>( assetId: _uniffiAssetId, ) @@ -20012,6 +20141,9 @@ external int uniffi_moq_ffi_checksum_method_moqclient_cancel(); @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqclient_connect(); +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqclient_tokens(); + @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqsession_auth(); @@ -20045,6 +20177,9 @@ external int uniffi_moq_ffi_checksum_method_moqsession_state_changed(); @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqsession_stats(); +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqtokens_set(); + @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqvideoconsumer_cancel(); @@ -20744,6 +20879,9 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_method_moqclient_connect() != 5016) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } + if (uniffi_moq_ffi_checksum_method_moqclient_tokens() != 18102) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } if (uniffi_moq_ffi_checksum_method_moqsession_auth() != 1680) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } @@ -20777,6 +20915,9 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_method_moqsession_stats() != 36396) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } + if (uniffi_moq_ffi_checksum_method_moqtokens_set() != 58423) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } if (uniffi_moq_ffi_checksum_method_moqvideoconsumer_cancel() != 21122) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } diff --git a/dart/moq_ffi/lib/src/uniffi_runtime.dart b/dart/moq_ffi/lib/src/uniffi_runtime.dart index 9018eb4e97e..c61178325f7 100644 --- a/dart/moq_ffi/lib/src/uniffi_runtime.dart +++ b/dart/moq_ffi/lib/src/uniffi_runtime.dart @@ -8,7 +8,6 @@ import "dart:ffi"; import "dart:io" show Platform, File, Directory; import "dart:isolate"; import "dart:typed_data"; - import "package:ffi/ffi.dart"; class UniffiInternalError implements Exception { @@ -251,7 +250,6 @@ mixin FfiConverterPrimitive on FfiConverter { @override T lower(T value) => value; } - Uint8List createUint8ListFromInt(int value) { int length = value.bitLength ~/ 8 + 1; if (length != 4 && length != 8) { diff --git a/doc/bin/cli.md b/doc/bin/cli.md index 72466365b83..81a733e177a 100644 --- a/doc/bin/cli.md +++ b/doc/bin/cli.md @@ -43,8 +43,11 @@ moq fetch [options] ``` The **MoQ side** goes first and attaches the process to the network: -`--connect ` dials a relay (the path is the auth path, `?jwt=` -carries a token), and `--broadcast ` names the broadcast. A process can +`--connect ` dials a relay (the path is the auth path), `--connect-token` +presents a token (repeat it to present several, see +[Tokens](/bin/relay/auth#tokens)), and `--broadcast ` names the +broadcast. A `?jwt=` in the URL still works, and is how a token reaches a +relay that predates `--connect-token`. A process can instead host sessions with `--listen`, or both at once, admitting clients the way the relay does (see [Authentication](/bin/relay/auth)). Listening imports and exports share the relay's shutdown behavior: SIGINT or SIGTERM sends accepted @@ -74,7 +77,7 @@ ffmpeg -re -i video.mp4 -c copy -f mpegts -pes_payload_size 0 -muxdelay 0 - | \ moq --connect https://relay.example.com/anon --broadcast my-stream.hang export ts | ffplay - # With a token -moq --connect "https://relay.example.com/rooms/1?jwt=$TOKEN" --broadcast alice.hang import ts +moq --connect https://relay.example.com/rooms/1 --connect-token "$TOKEN" --broadcast alice.hang import ts ``` ## Import diff --git a/doc/bin/relay/auth.md b/doc/bin/relay/auth.md index 12f808417c7..c55c7bcad9f 100644 --- a/doc/bin/relay/auth.md +++ b/doc/bin/relay/auth.md @@ -132,10 +132,21 @@ moq auth verify --key public.jwk --in alice.jwt moq auth serve --key public.jwk # or --key-dir /etc/moq/keys/ or --key-set /etc/moq/keys.jwks ``` -The client dials `https://relay.example.com/rooms/123?jwt=`, with one -`jwt`: a query carrying two is refused. A -moq-transport client can instead put the JWT in its SETUP's `AUTHORIZATION -TOKEN` option with Token Type 0. HMAC, RSA, ECDSA, and EdDSA keys all work. A +A native client passes it as `--connect-token ` (or `MOQ_CONNECT_TOKEN`) +and dials `https://relay.example.com/rooms/123`. The first token is the +connection credential, sent in SETUP: moq-lite-07-wip's Token parameter, or +moq-transport's `AUTHORIZATION TOKEN` option with Token Type 0. While the client +also offers a version that cannot carry it (moq-lite before 07), it sends the +same value as `?jwt=`, which `moq auth serve` admits since they match; two +different values are refused. A URL token can land in server and proxy access +logs, so to keep it out, offer only versions that carry it in SETUP, such as +`--connect-version moq-lite-07-wip`; a relay that predates SETUP tokens then +cannot receive it. Each further token is presented +[in band](#in-band-renewal) and widens the session's grant. A URL that already +carries a `?jwt=`, such as a cluster peer's, keeps it as the connection +credential, and every configured token then rides in band. Dialing +`?jwt=` directly still works, and reaches relays that predate SETUP +tokens; a query carrying two `jwt` parameters is refused. 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. @@ -146,7 +157,10 @@ a client can present another JWT without reconnecting. Present its replacement before the current grant expires and keep the returned token handle alive. The auth handle a reconnecting connection owns (native `Connection::auth()`, JavaScript `connection.auth`) presents its tokens again on every reconnect. -Older versions refuse in-band tokens. +A native client can instead replace its configured tokens through +`Client::tokens()`: the next dial presents them, a live session presents them in +band and withdraws each old one once its replacement is answered, and a refusal +redials only once the tokens have changed. 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 diff --git a/doc/concept/transport.md b/doc/concept/transport.md index 5c52b567907..cc92fd1e954 100644 --- a/doc/concept/transport.md +++ b/doc/concept/transport.md @@ -48,8 +48,10 @@ WebSocket stays there until it ends; the next connect races again. ## Raw QUIC (native) Native clients dial the relay directly with the MoQ ALPN, skipping the HTTP/3 -handshake. The URL path and `?jwt=` token travel in the MoQ `SETUP` message +handshake. The URL path and any `?jwt=` token travel in the MoQ `SETUP` message instead of a `CONNECT` request, so the server sees the same thing either way. +On every transport, a SETUP token (moq-lite-07-wip and moq-transport) can carry +the credential in place of the query. `moq-tokio` dials WebTransport for `https://` URLs; use `moql://` or `moqt://` for raw QUIC. A browser cannot dial raw QUIC, so `@moq/net` reaches a `moqt://` or `moql://` URL over WebTransport at the same host, port, and path. diff --git a/doc/lib/c/index.md b/doc/lib/c/index.md index 4174047f9c0..9f5a5df0117 100644 --- a/doc/lib/c/index.md +++ b/doc/lib/c/index.md @@ -62,6 +62,7 @@ inside. The header is the reference: each function carries a doc comment. - **Handles and callbacks.** Every object is an integer handle, and every async result arrives on a `moq_status_callback` with your `void *user_data`. A status `> 0` is a live result, `0` a clean close, and `< 0` an error. The last two are terminal: moq-c never touches `user_data` again, so free it there. `*_close` and `*_cancel` only request shutdown; the terminal callback still fires. A session reports on a `moq_state_callback` instead: each `moq_connection_state` carries its `phase`, `connects`, the negative `failure` of the latest attempt, and the live `protocol`, and the `MOQ_CONNECTION_PHASE_CLOSED` snapshot is terminal, delivered after the handle is released. `moq_session_close()` drains finished tracks for dialed and accepted sessions; wait for that terminal snapshot to learn the result. - **Errors.** Negative return codes are named by `MOQ_ERROR_*`, and `moq_error()` gives the reason on the calling thread. Auth rejections have their own codes so you don't retry them. For the peer's protocol code use `moq_error_protocol()`; do not parse `moq_error()`. A panic in a binding background task reports `MOQ_ERROR_PANIC` through its terminal callback; unrelated objects keep running. A session closed because its credential expired has kind `MOQ_PROTOCOL_KIND_EXPIRED`, and a request refused for an expired token returns `MOQ_ERROR_EXPIRED`; refresh the token before retrying. A catalog that needs a newer moq-hang draft than this build reads returns `MOQ_ERROR_UNSUPPORTED_CATALOG`; upgrade the library to read it. A request-cap closure has kind `MOQ_PROTOCOL_KIND_TOO_MANY_REQUESTS`; reduce the workload or adjust the relay's [session limits](/bin/relay/config#session-and-peer) before reconnecting. +- **Tokens.** Set `moq_client_config.tokens` instead of putting the credential in the URL. The first is the connection credential, sent in SETUP and, while an offered version can't carry it there (lite before 07), as the URL's `?jwt=`; each other token widens the grant over AUTH. A `?jwt=` already in the URL stays the connection credential, which is how to reach a relay older than this release. To replace expiring tokens, close the session and connect again. - **Threading.** Call any function from any thread. Raw publish calls block until the codec takes the frame, which paces a publisher. - **Zeroed config is the default.** A zeroed `moq_client_config` means the default for every field, so new fields don't break callers. A field whose default isn't zero has a `has_*` flag: `backoff_timeout_us = 0` means "retry forever" only with `has_backoff_timeout = true`. - **Compressed tracks.** `moq_track_info.compress` on a raw track sends each group DEFLATE-compressed to opt-in `moq-lite-07-wip` peers; other peers, and every reader here, get the original frames. `@moq/net` cannot read a compressed track yet. diff --git a/doc/lib/cpp/index.md b/doc/lib/cpp/index.md index 9824120a11b..bcb3938b17b 100644 --- a/doc/lib/cpp/index.md +++ b/doc/lib/cpp/index.md @@ -212,6 +212,8 @@ Obtain a dynamic track request’s `demand()` before accepting it to watch subsc On `moq-lite-07-wip` (opt in with `ClientConfig::versions`) and on moq-transport draft-17+ with the [MoQ Auth extension](/draft/moq-auth), `session->auth()->grant()` is the `Grant` the relay sent back, empty until it answers and while a client session reconnects; `grant_changed()` waits for the next one. `add(token)` presents a fresh token without reconnecting and resolves to an `AuthToken` whose grant joins the union until `cancel()`; it is presented again on every reconnect. A refusal resolves to an `Error` carrying the relay's code. +Set `ClientConfig::tokens` instead of putting the credential in the URL. The first is the connection credential, sent in SETUP and, while an offered version can't carry it there (lite before 07), as the URL's `?jwt=`; each other token widens the grant over AUTH. `client->tokens()->set(...)` replaces them before they expire: the next dial presents them, a live AUTH session at once, and after an authorization refusal the client redials only if they changed. A `?jwt=` already in the URL stays the connection credential, which is how to reach a relay older than this release. + ## Stats `session->stats()` reports the transport counters, each a `std::optional` that is empty when the transport does not report it, plus the negotiated protocol in `version`. Connect returns a connecting session. Read `session->state()` and wait with `state_changed(state.revision)` to observe its first-dial failures and reconnects; independent watchers keep their own revisions. diff --git a/doc/lib/dart/index.md b/doc/lib/dart/index.md index 05a9fe8914f..5422702dab9 100644 --- a/doc/lib/dart/index.md +++ b/doc/lib/dart/index.md @@ -145,6 +145,7 @@ shows which module holds each. - **Ending cleanly.** When the input ends, finish every track, then call `finish()` on its `CatalogProducer` to finish the catalog, which ends the content. The broadcast stays announced and keeps serving the finished tracks, so a subscriber still catching up can drain them, until `broadcast.close()` takes it offline. How long to keep it up is yours to decide. - **Stats.** `session.stats()` reports `rtt`, `estimatedSendRateBps`, and the byte and packet counters (`bytesSent`, `bytesReceived`, `bytesLost`, `packetsSent`, `packetsReceived`, `packetsLost`). A field is `null` when the transport does not report it, which is not the same as zero. `version` names the negotiated protocol. Connect returns a connecting session; read its coherent `state()` snapshot and wait with that snapshot's revision to observe first-dial failures and reconnects. Each watcher keeps its own revision. Hold the owned `BandwidthConsumer` returned by `session.recvBandwidth()` to receive bandwidth estimates. `rate()` reads the current estimate in bits per second and `rateChanged()` waits for a new one. Release the consumer or call `cancel()` when finished; releasing the last consumer stops PROBE; the session and stats snapshots do not request it. Client consumers survive reconnects and report no estimate while disconnected. - **Authorization.** On `moq-lite-07-wip` (opt in with `ConnectOptions(versions: ...)`) and on moq-transport draft-17+ with the [MoQ Auth extension](/draft/moq-auth), listen to `moq.auth().grants()` for the `Grant` the relay sent back, `null` until it answers and while a client session reconnects. `await auth().add(token: ...)` presents a fresh token without reconnecting, returns an `AuthToken` whose grant joins the union until `cancel()`, and is presented again on every reconnect. A refusal throws with the relay's code. +- **Tokens.** Pass `ConnectOptions(tokens: [...])` instead of putting the credential in the URL. The first is the connection credential, sent in SETUP and, while an offered version can't carry it there (lite before 07), as the URL's `?jwt=`; each other token widens the grant over AUTH. `moq.tokens().set_(tokens: [...])` replaces them before they expire: the next dial presents them, a live AUTH session at once, and after an authorization refusal the client redials only if they changed. A `?jwt=` already in the URL stays the connection credential, which is how to reach a relay older than this release. - **Errors.** Read diagnostic payloads from `reason` and structured protocol failures from `details`. - **Disabled renditions.** Audio and video convenience decoders reject a catalog configuration with `enabled: false`. Select an enabled rendition from the latest catalog before opening a decoder. Among the video renditions you can decode, prefer the highest `preference`; `0` is the default and `-1` marks a compatibility transcode. - **Track demand.** Obtain a dynamic track request’s `demand()` before accepting it to watch subscribers through the handoff to its producer. The watch does not keep the track alive and ends when the track closes or the request is rejected. diff --git a/doc/lib/go/index.md b/doc/lib/go/index.md index 703a20b27c7..41892cf6adc 100644 --- a/doc/lib/go/index.md +++ b/doc/lib/go/index.md @@ -135,6 +135,7 @@ shows which module holds each. - **Ending cleanly.** When the input ends, finish every track, then call `Finish()` on its `media.CatalogProducer` to finish the catalog, which ends the content. The broadcast stays announced and keeps serving the finished tracks, so a subscriber still catching up can drain them, until `broadcast.Close()` takes it offline. How long to keep it up is yours to decide. - **Stats.** `Session().Stats()` reports `RTT`, `EstimatedSendRateBps`, and the byte and packet counters (`BytesSent`, `BytesReceived`, `BytesLost`, `PacketsSent`, `PacketsReceived`, `PacketsLost`). A field is nil when the transport does not report it, which is not the same as zero. `Version` names the negotiated protocol. `Dial` returns a connecting session; read `Session().State()` and wait with `StateChanged(ctx, state.Revision)` to observe first-dial failures and reconnects. Hold the owned `BandwidthConsumer` returned by `Session().RecvBandwidth()` to receive bandwidth estimates. `Rate()` reads the current estimate in bits per second and `RateChanged(ctx)` waits for a new one. Calling `Close()` on the last consumer stops PROBE; the session and stats snapshots do not request it. Client consumers survive reconnects and report no estimate while disconnected. - **Authorization.** On `moq-lite-07-wip` (opt in with `ClientConfig.Versions`) and on moq-transport draft-17+ with the [MoQ Auth extension](/draft/moq-auth), `Session().Auth().Grant()` is the `Grant` the relay sent back, nil until it answers and while a client session reconnects; `GrantChanged(ctx)` waits for the next one. `Add(ctx, token)` presents a fresh token without reconnecting, returns an `AuthToken` whose grant joins the union until `Close()`, and is presented again on every reconnect. A refusal returns the relay's code. +- **Tokens.** Set `ClientConfig.Tokens` instead of putting the credential in the URL. The first is the connection credential, sent in SETUP and, while an offered version can't carry it there (lite before 07), as the URL's `?jwt=`; each other token widens the grant over AUTH. `client.Tokens().Set(...)` replaces them before they expire: the next dial presents them, a live AUTH session at once, and after an authorization refusal the client redials only if they changed. A `?jwt=` already in the URL stays the connection credential, which is how to reach a relay older than this release. - **Errors.** Read diagnostic payloads from `Reason` and structured protocol failures from `Details`. - **Disabled renditions.** Audio and video convenience decoders reject a catalog configuration with `enabled: false`. Select an enabled rendition from the latest catalog before opening a decoder. Among the video renditions you can decode, prefer the highest `Preference`; `0` is the default and `-1` marks a compatibility transcode. - **Track demand.** Obtain a dynamic track request’s `Demand()` before accepting it to watch subscribers through the handoff to its producer. The watch does not keep the track alive and ends when the track closes or the request is rejected. diff --git a/doc/lib/js/net.md b/doc/lib/js/net.md index c6050cf7a59..92f66f02bc5 100644 --- a/doc/lib/js/net.md +++ b/doc/lib/js/net.md @@ -15,12 +15,15 @@ the types do not spell out. ```ts import * as Moq from "@moq/net"; -const url = new URL("https://cdn.moq.dev/anon?jwt=..."); +const url = new URL("https://cdn.moq.dev/room"); // The origin is the routing table. The broadcast survives a reconnect. const origin = new Moq.Origin.Producer(); const connection = await Moq.Connection.connect({ url, + // The first token rides SETUP (and `?jwt=` for older versions); others widen the grant. + // A reloading `new Moq.Connection({ tokens })` also takes a Signal, set to replace them. + tokens: [token], publish: origin.consume(), consume: origin, }); diff --git a/doc/lib/kt/index.md b/doc/lib/kt/index.md index 4155975321f..d1ff3bff5d1 100644 --- a/doc/lib/kt/index.md +++ b/doc/lib/kt/index.md @@ -105,6 +105,7 @@ shows which module holds each. - **Ending cleanly.** When the input ends, finish every track, then call `finish()` on its `CatalogProducer` to finish the catalog, which ends the content. The broadcast stays announced and keeps serving the finished tracks, so a subscriber still catching up can drain them, until `broadcast.end()` takes it offline. How long to keep it up is yours to decide. - **Stats.** `session.stats()` reports `rtt`, `estimatedSendRateBps`, and the byte and packet counters (`bytesSent`, `bytesReceived`, `bytesLost`, `packetsSent`, `packetsReceived`, `packetsLost`). A field is `null` when the transport does not report it, which is not the same as zero. `version` names the negotiated protocol. Connect returns a connecting session; read its coherent `state()` snapshot and wait with that snapshot's revision to observe first-dial failures and reconnects. Each watcher keeps its own revision. Hold the owned `BandwidthConsumer` returned by `session.recvBandwidth()` to receive bandwidth estimates. `rate()` reads the current estimate in bits per second and `rateChanged()` waits for a new one. Release the consumer or call `cancel()` when finished; releasing the last consumer stops PROBE; the session and stats snapshots do not request it. Client consumers survive reconnects and report no estimate while disconnected. - **Authorization.** On `moq-lite-07-wip` (opt in with `ClientConfig(versions = ...)`) and on moq-transport draft-17+ with the [MoQ Auth extension](/draft/moq-auth), collect `moq.auth().grants()` for the `Grant` the relay sent back, `null` until it answers and while a client session reconnects. Suspending `auth().add(token)` presents a fresh token without reconnecting, returns an `AuthToken` whose grant joins the union until `cancel()`, and is presented again on every reconnect. A refusal throws with the relay's code. +- **Tokens.** Pass `ClientConfig(tokens = listOf(...))` instead of putting the credential in the URL. The first is the connection credential, sent in SETUP and, while an offered version can't carry it there (lite before 07), as the URL's `?jwt=`; each other token widens the grant over AUTH. `moq.tokens().set(listOf(...))` replaces them before they expire: the next dial presents them, a live AUTH session at once, and after an authorization refusal the client redials only if they changed. A `?jwt=` already in the URL stays the connection credential, which is how to reach a relay older than this release. - **Errors.** Read diagnostic payloads from `reason` and structured protocol failures from `details`. - **Disabled renditions.** Audio and video convenience decoders reject a catalog configuration with `enabled: false`. Select an enabled rendition from the latest catalog before opening a decoder. Among the video renditions you can decode, prefer the highest `preference`; `0` is the default and `-1` marks a compatibility transcode. - **Track demand.** Obtain a dynamic track request’s `demand()` before accepting it to watch subscribers through the handoff to its producer. The watch does not keep the track alive and ends when the track closes or the request is rejected. diff --git a/doc/lib/py/index.md b/doc/lib/py/index.md index 1799e4881d2..96cb78bdd97 100644 --- a/doc/lib/py/index.md +++ b/doc/lib/py/index.md @@ -119,6 +119,7 @@ shows which module holds each. - **Ending cleanly.** When the input ends, finish every track, then call `finish()` on its `moq.media.CatalogProducer` to finish the catalog, which ends the content. The broadcast stays announced and keeps serving the finished tracks, so a subscriber still catching up can drain them, until `broadcast.close()` takes it offline. How long to keep it up is yours to decide. - **Stats.** `session.stats()` reports `rtt`, `estimated_send_rate_bps`, and the byte and packet counters (`bytes_sent`, `bytes_received`, `bytes_lost`, `packets_sent`, `packets_received`, `packets_lost`). A field is `None` when the transport does not report it, which is not the same as zero. `version` names the negotiated protocol. Connect returns a connecting session; read its coherent `state()` snapshot and wait with that snapshot's revision to observe first-dial failures and reconnects. Each watcher keeps its own revision. Hold the owned `Bandwidth.Consumer` returned by `session.recv_bandwidth()` to receive bandwidth estimates. `rate()` reads the current estimate in bits per second and `rate_changed()` waits for a new one. Release the consumer or call `cancel()` when finished; releasing the last consumer stops PROBE; the session and stats snapshots do not request it. Client consumers survive reconnects and report no estimate while disconnected. - **Authorization.** On `moq-lite-07-wip` (opt in with `versions=`) and on moq-transport draft-17+ with the [MoQ Auth extension](/draft/moq-auth), `session.auth().grant()` is the `Grant` the relay sent back, `None` until it answers and while a client session reconnects; `await auth.grant_changed()` waits for the next one. `await auth.add(token)` presents a fresh token without reconnecting, returns an `AuthToken` whose grant joins the union until `cancel()`, and is presented again on every reconnect. A refusal raises with the relay's code. +- **Tokens.** Pass `tokens=[...]` instead of putting the credential in the URL. The first is the connection credential, sent in SETUP and, while an offered version can't carry it there (lite before 07), as the URL's `?jwt=`; each other token widens the grant over AUTH. `client.tokens().set([...])` replaces them before they expire: the next dial presents them, a live AUTH session at once, and after an authorization refusal the client redials only if they changed. A `?jwt=` already in the URL stays the connection credential, which is how to reach a relay older than this release. - **Errors.** Read diagnostic payloads from `reason` and structured protocol failures from `details`. - **Disabled renditions.** Audio and video convenience decoders reject a catalog configuration with `enabled: false`. Select an enabled rendition from the latest catalog before opening a decoder. Among the video renditions you can decode, prefer the highest `preference`; `0` is the default and `-1` marks a compatibility transcode. - **Track demand.** Obtain a dynamic track request’s `demand()` before accepting it to watch subscribers through the handoff to its producer. The watch does not keep the track alive and ends when the track closes or the request is rejected. diff --git a/doc/lib/rs/index.md b/doc/lib/rs/index.md index 9e67f860511..eb924d763c1 100644 --- a/doc/lib/rs/index.md +++ b/doc/lib/rs/index.md @@ -92,8 +92,12 @@ already queued, then `Client::close` sends the QUIC close. Runnable examples: [`subscribe.rs`](https://github.com/moq-dev/moq/blob/main/rs/hang/examples/subscribe.rs). URLs may be `https://` (QUIC or WebTransport, with a WebSocket fallback), -`moql://`/`moqt://` (raw QUIC), or `iroh://`. A `?jwt=` query carries the -token. `http://` is for a relay on localhost only: it fetches the certificate +`moql://`/`moqt://` (raw QUIC), or `iroh://`. Put credentials in +`connect::Config::tokens` rather than the URL: the first rides SETUP (and +`?jwt=` while an offered version needs it), each other widens the grant in band, +and `Client::tokens().set(...)` replaces them before they expire. A `?jwt=` +already in the URL stays the connection credential, which reaches an older +relay. `http://` is for a relay on localhost only: it fetches the certificate fingerprint unauthenticated, so never send a token over it. QUIC tuning shares the relay's settings; see the [relay reference](/bin/relay/config#quic). diff --git a/doc/lib/swift/index.md b/doc/lib/swift/index.md index efdbca0d66e..23f279b2271 100644 --- a/doc/lib/swift/index.md +++ b/doc/lib/swift/index.md @@ -105,6 +105,7 @@ shows which module holds each. - **Ending cleanly.** When the input ends, finish every track, then call `try finish()` on its `Media.CatalogProducer` to finish the catalog, which ends the content. The broadcast stays announced and keeps serving the finished tracks, so a subscriber still catching up can drain them, until `broadcast.close()` takes it offline. How long to keep it up is yours to decide. - **Stats.** `session.stats()` reports `rtt`, `estimatedSendRateBps`, and the byte and packet counters (`bytesSent`, `bytesReceived`, `bytesLost`, `packetsSent`, `packetsReceived`, `packetsLost`). A field is `nil` when the transport does not report it, which is not the same as zero. `version` names the negotiated protocol. Connect returns a connecting session; read its coherent `state()` snapshot and wait with that snapshot's revision to observe first-dial failures and reconnects. Each watcher keeps its own revision. Hold the owned `Bandwidth.Consumer` returned by `session.recvBandwidth()` to receive bandwidth estimates. `rate()` reads the current estimate in bits per second and `rateChanged()` waits for a new one. Release the consumer or call `cancel()` when finished; releasing the last consumer stops PROBE; the session and stats snapshots do not request it. Client consumers survive reconnects and report no estimate while disconnected. - **Authorization.** On `moq-lite-07-wip` (opt in with `Client(versions:)`) and on moq-transport draft-17+ with the [MoQ Auth extension](/draft/moq-auth), `session.auth().grant()` is the `Grant` the relay sent back, `nil` until it answers and while a client session reconnects; `try await auth.grantChanged()` waits for the next one. `try await auth.add(token:)` presents a fresh token without reconnecting, returns an `AuthToken` whose grant joins the union until `cancel()`, and is presented again on every reconnect. A refusal throws with the relay's code. +- **Tokens.** Pass `Client(tokens: [...])` instead of putting the credential in the URL. The first is the connection credential, sent in SETUP and, while an offered version can't carry it there (lite before 07), as the URL's `?jwt=`; each other token widens the grant over AUTH. `try client.tokens().set([...])` replaces them before they expire: the next dial presents them, a live AUTH session at once, and after an authorization refusal the client redials only if they changed. A `?jwt=` already in the URL stays the connection credential, which is how to reach a relay older than this release. - **Errors.** Diagnostic cases use the `reason:` argument label; match their associated value to inspect it. Protocol errors carry `details`. - **Disabled renditions.** Audio and video convenience decoders reject a catalog configuration with `enabled: false`. Select an enabled rendition from the latest catalog before opening a decoder. Among the video renditions you can decode, prefer the highest `preference`; `0` is the default and `-1` marks a compatibility transcode. - **Track demand.** Obtain a dynamic track request’s `demand()` before accepting it to watch subscribers through the handoff to its producer. The watch does not keep the track alive and ends when the track closes or the request is rejected. diff --git a/drafts/draft-lcurley-moq-lite.md b/drafts/draft-lcurley-moq-lite.md index 03bd38eb7e6..d75578001df 100644 --- a/drafts/draft-lcurley-moq-lite.md +++ b/drafts/draft-lcurley-moq-lite.md @@ -616,7 +616,7 @@ If the publisher advertised no Probe capability (e.g., the congestion controller Either endpoint can open an Auth Stream (0x7) to present a token and learn what it grants. The opener sends a single AUTH message and keeps its side open for as long as the token applies. -Each endpoint SHOULD open one Auth Stream with an empty token right after the session starts, so both learn what the credential the connection already presented (the request URI, a client certificate, or nothing) allows. +Each endpoint SHOULD open one Auth Stream with an empty token right after the session starts, so both learn what the credential the connection already presented (the [Token](#token-parameter) parameter, the request URI, a client certificate, or nothing) allows. The acceptor replies with an AUTH_OK carrying the grant, or an AUTH_ERROR refusing the token. The grant MUST join the acceptor's scope before it sends AUTH_OK. 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. @@ -929,6 +929,8 @@ The following Setup Parameters are defined: |------|----------------|-------------| | 0x6 | Implementation | Tokens (s) | |------|----------------|-------------| +| 0x7 | Token | Token (b) | +|------|----------------|-------------| ### Probe Parameter {#probe-parameter} The Probe Parameter advertises the sender's capability level when acting as a publisher on a [Probe Stream](#probe). @@ -997,6 +999,17 @@ A receiver that receives an invalid UTF-8 value MUST close the session with a PR Either endpoint MAY send it, and MAY omit it to avoid disclosing its software. A receiver MUST NOT authorize on it or change its protocol behavior because of it. A relay MUST NOT forward it. +### Token Parameter {#token-parameter} +The Token Parameter carries the client's connection credential, such as a JWT, so a server can authorize the session from the SETUP it already reads before serving. +The Parameter Value is the token's bytes, in a format the endpoints agree on out of band; it MUST NOT be empty, and a receiver MUST close the session with a PROTOCOL_VIOLATION on an empty value. + +It is the credential the empty-token [Auth Stream](#auth-stream) refers to, so the client presents any other token on its own Auth Stream rather than here. +A server SHOULD verify it as it would a credential in the request URI's query. +A client whose request URI already carries a credential SHOULD NOT also send a different one here, and a server MAY refuse a session that presents two different credentials. +The URI is fixed before the version is negotiated, so a client that may negotiate a version without this parameter carries the same credential in the query as well. + +Only the client sends it; a client that receives one MUST close the session with a PROTOCOL_VIOLATION. A relay MUST NOT forward it. + ## ANNOUNCE_REQUEST {#announce-request} A subscriber sends an ANNOUNCE_REQUEST message to indicate it wants to receive announcements for any broadcasts with a path that starts with the requested prefix. @@ -1854,6 +1867,7 @@ The `Message Length` describes the payload size on the wire. - An open Track Stream is interest in the Track until the subscriber closes it, which a subscriber does once its Subscribe Stream has a response. lite-05 and lite-06 implementations do the same. - Assigned `moq-lite-07-wip` as this draft's protocol identifier until it is finalized as `moq-lite-07`. +- Added the client-only Token Setup Parameter (0x7), carrying the connection credential in SETUP instead of only the request URI. - Switched every variable-length integer, including SETUP parameter values, from QUIC's two-bit length prefix to moq-transport's leading-ones encoding, widening the range to 64 bits. - Hid routes with a `.`-prefixed segment below the requested prefix from announce discovery, and added the ANNOUNCE_REQUEST `Hidden` field to opt in. - Restored SUBSCRIBE_DROP (type 0x2), replacing Stream Count on SUBSCRIBE_END. Every sequence is accounted for by a Group Stream, a datagram, or a drop; a publisher SHOULD send a drop as soon as it gives a sequence up. diff --git a/go/wrapper/client.go b/go/wrapper/client.go index 62cd819069f..c60bc8dc4a2 100644 --- a/go/wrapper/client.go +++ b/go/wrapper/client.go @@ -37,6 +37,10 @@ type ClientConfig struct { // spelled like "moq-lite-03". Empty offers every supported version, which // the Versions function lists. Versions []string + // Tokens are the credentials every dial presents. The first is the + // connection credential, unless the URL carries its own ?jwt=; each other + // one widens the session's grant. Replace them with Client.Tokens. + Tokens []string // Timeout bounds one connect, dial and handshake together. Zero waits forever. Timeout *time.Duration // Race is the Happy Eyeballs stagger: how long before the next resolved @@ -162,6 +166,7 @@ func (c ClientConfig) ffi() (ffi.MoqClientConfig, error) { cfg := ffi.MoqClientConfig{ Bind: str(c.Bind), Versions: c.Versions, + Tokens: c.Tokens, Timeout: c.Timeout, Race: c.Race, ResolutionDelay: c.ResolutionDelay, @@ -321,6 +326,25 @@ func (c *Client) RequestBroadcast(ctx context.Context, path string) (*BroadcastC return c.consumer.RequestBroadcast(ctx, path) } +// Tokens returns the credentials the client presents, to replace while it runs. +func (c *Client) Tokens() *Tokens { + return &Tokens{inner: c.inner.Tokens()} +} + +// Tokens are the credentials a Client presents, replaceable while it runs. +type Tokens struct { + inner *ffi.MoqTokens +} + +// Set replaces the credentials every dial presents, such as before the current +// ones expire. The next dial presents them, and a live session with in-band +// AUTH presents them at once, withdrawing each replaced token once its +// replacements are answered, even when one is refused. After an authorization +// refusal the client redials only if they changed. An empty token is refused. +func (t *Tokens) Set(tokens ...string) error { + return t.inner.Set(append([]string(nil), tokens...)) +} + // Session returns the underlying session. Hold the client (or session) to keep // the connection alive. func (c *Client) Session() *Session { diff --git a/js/net/api/index.api.md b/js/net/api/index.api.md index 1afd5d29b42..c7ba943531d 100644 --- a/js/net/api/index.api.md +++ b/js/net/api/index.api.md @@ -177,6 +177,7 @@ interface ConnectionProps { share?: boolean; signal?: never; timeout?: Milli; + tokens?: readonly string[] | Signal; transport?: never; url?: URL | Signal; versions?: readonly string[] | Signal; @@ -191,6 +192,7 @@ interface ConnectProps { publish?: Consumer_5; signal?: AbortSignal; timeout?: Milli; + tokens?: readonly string[]; transport?: WebTransport; url: URL; versions?: readonly string[]; diff --git a/js/net/src/connection/connect.ts b/js/net/src/connection/connect.ts index 4da8769cd11..672f8319c90 100644 --- a/js/net/src/connection/connect.ts +++ b/js/net/src/connection/connect.ts @@ -18,6 +18,7 @@ import type { Established } from "./established.ts"; import * as Extensions from "./extensions.ts"; import { DISCOVERY_REMOVED, forwardAnnounced } from "./forward.ts"; import { exchangeSetup, legacyPeerImplementation } from "./handshake.ts"; +import { type Credential, credential, fallback, hold } from "./tokens.ts"; // Default head start for WebTransport before attempting the WebSocket fallback. const DEFAULT_WEBSOCKET_DELAY_MS = Milli(500); @@ -124,6 +125,13 @@ interface Offer { setup: SetupVersion[]; /** Whether the caller named the versions, so the peer must pick one of them. */ explicit: boolean; + /** Whether every offered version carries the connection credential in SETUP. */ + setupToken: boolean; +} + +// moq-lite gained the SETUP token in lite-07; every moq-transport draft has one. +function hasSetupToken(name: string): boolean { + return !name.startsWith("moq-lite-") || name === "moq-lite-07-wip"; } function offer(versions?: readonly string[]): Offer { @@ -136,7 +144,7 @@ function offer(versions?: readonly string[]): Offer { alpns.add(version.alpn); if (version.setup !== undefined) setup.push(version.setup); } - return { alpns: [...alpns], setup, explicit: versions !== undefined }; + return { alpns: [...alpns], setup, explicit: versions !== undefined, setupToken: names.every(hasSetupToken) }; } /** Validate and snapshot an ordered version list before acquiring a connection. @internal */ @@ -222,6 +230,16 @@ export interface ConnectProps { */ extensions?: Extensions.Extensions; + /** + * Credentials to present, kept apart from the URL. + * + * The first is the connection credential: it rides SETUP, and the URL's `jwt` query too + * while an offered version can't carry it in SETUP (moq-lite before 07). Each other token + * rides its own AUTH stream, widening the session's grant. A URL that already has a `jwt` + * keeps it as the connection credential, and then every token rides AUTH. + */ + tokens?: readonly string[]; + /** * Build the session on `auth`, so it presents every token the handle holds and a route * naming one of its `Auth`s keeps it across a reconnect. Omit to give the session a handle @@ -262,6 +280,8 @@ type SessionProps = { client: boolean; /** The moq-transport extensions this side offers. */ extensions: Extensions.Offered; + /** The connection credential SETUP presents, on versions that carry one. */ + token?: Uint8Array; /** The implementation this side advertises in SETUP; empty sends nothing. */ implementation: string; }; @@ -305,17 +325,21 @@ export async function connect({ url, ...props }: ConnectProps): Promise {}; - const pending = connectInner(url, props, abort); + const tokens = props.tokens ?? []; + const pending = connectInner(url, tokens, props, abort); try { // A `pending` rejection propagates unless the abort or timeout beat it to the finish line. - const connection = await Promise.race([pending, abort.then(() => undefined)]); - if (connection && !signal.aborted && !expired) { + const connected = await Promise.race([pending, abort.then(() => undefined)]); + if (connected && !signal.aborted && !expired) { + const [connection, dial] = connected; if (props.consume) forwardAnnounced(connection, props.consume); + // Before the caller sees the session, so its first requests wait on these answers. + hold(connection, dial, tokens); return connection; } // Close a connection that settles after the abort or timeout. - pending.then((conn) => conn.abort()).catch(() => {}); + pending.then(([conn]) => conn.abort()).catch(() => {}); // The caller's abort is teardown and wins over the timeout. throw signal.aborted ? signal.reason : expired; } finally { @@ -335,22 +359,30 @@ function dialable(url: URL): URL { return new URL(`https:${url.href.slice(url.protocol.length)}`); } -async function connectInner(raw: URL, props: Omit, abort: Promise): Promise { - const url = dialable(raw); - const wiring: SessionProps = { +/** Dial `raw`, resolving with the session and the credential the winning transport presented. */ +async function connectInner( + raw: URL, + tokens: readonly string[], + props: Omit, + abort: Promise, +): Promise<[Established, Credential]> { + const offered = offer(props.versions); + const dial = credential(raw, tokens, offered.setupToken); + const url = dialable(dial.url); + const fallbackDial = props.websocket?.url && fallback(raw, props.websocket.url, tokens, offered.setupToken); + const wiring = (presented: Credential): SessionProps => ({ publish: props.publish, handle: props.auth, client: true, extensions: Extensions.offered(props.extensions), + token: presented.setup === undefined ? undefined : new TextEncoder().encode(presented.setup), implementation: props.implementation ?? IMPLEMENTATION, - }; - - const offered = offer(props.versions); + }); if (props.transport) { const transport = props.transport; void abort.then(() => transport.close()); - return connectTransport(url, transport, wiring, offered.setup); + return [await connectTransport(url, transport, wiring(dial), offered.setup), dial]; } // Stop transports after one connects or the caller aborts. @@ -367,7 +399,7 @@ async function connectInner(raw: URL, props: Omit, abort: P !webtransport || websocketWon.has(url.toString()) ? 0 : (props.websocket?.delay ?? DEFAULT_WEBSOCKET_DELAY_MS); const websocket = props.websocket?.enabled !== false - ? connectWebSocket(props.websocket?.url ?? url, headstart, cancel, offered.alpns) + ? connectWebSocket(fallbackDial?.url ?? url, headstart, cancel, offered.alpns) : undefined; if (!websocket && !webtransport) { @@ -414,7 +446,8 @@ async function connectInner(raw: URL, props: Omit, abort: P session.close(); throw new Error("server negotiated no protocol, and no offered version negotiates in SETUP"); } - return await connectTransport(url, session as WebTransport, wiring, offered.setup); + const won = session instanceof Session && fallbackDial ? fallbackDial : dial; + return [await connectTransport(url, session as WebTransport, wiring(won), offered.setup), won]; } async function connectTransport( @@ -506,6 +539,7 @@ async function negotiate( Ietf.hiddenIntoSetup(params); Ietf.maxAgeIntoSetup(params); Ietf.activeCountIntoSetup(params, setupVersion); + if (wiring.token) Ietf.tokenIntoSetup(params, { kind: Ietf.TOKEN_OUT_OF_BAND, value: wiring.token }, setupVersion); const client = new Ietf.ClientSetup({ versions: diff --git a/js/net/src/connection/handshake.ts b/js/net/src/connection/handshake.ts index 3c429eab0a2..ac4bcd3725a 100644 --- a/js/net/src/connection/handshake.ts +++ b/js/net/src/connection/handshake.ts @@ -24,7 +24,7 @@ export async function exchangeSetup( transport: WebTransport, version: Ietf.IetfVersion, implementation: string, - { client, extensions }: { client: boolean; extensions: Extensions.Offered }, + { client, extensions, token }: { client: boolean; extensions: Extensions.Offered; token?: Uint8Array }, ): Promise<{ control: Stream; early: Reader[]; @@ -43,6 +43,8 @@ export async function exchangeSetup( Ietf.hiddenIntoSetup(params); Ietf.maxAgeIntoSetup(params); Ietf.activeCountIntoSetup(params, version); + // Only a client presents a connection credential. + if (client && token) Ietf.tokenIntoSetup(params, { kind: Ietf.TOKEN_OUT_OF_BAND, value: token }, version); // One id per session, like the moq-lite connection: nothing in this process forwards // between sessions, so there is nothing for a shared id to detect. diff --git a/js/net/src/connection/pool.ts b/js/net/src/connection/pool.ts index e6f09fe9b01..4076c53c30b 100644 --- a/js/net/src/connection/pool.ts +++ b/js/net/src/connection/pool.ts @@ -71,6 +71,12 @@ export interface ConnectionProps { /** Backoff settings for the reconnect loop; an unset field uses its default. */ delay?: ReloadDelay; + /** + * Credentials to present, kept apart from the URL; see {@link ConnectProps.tokens}. Pass a + * `Signal` to replace them before they expire. Implies a private loop. + */ + tokens?: readonly string[] | Signal; + /** * Maximum time for each connection attempt, the dial and the MoQ handshake (default: * 30000ms; `0` waits forever). An attempt that outlives it is retried like any other @@ -112,7 +118,7 @@ export interface ConnectionProps { * session finishes its groups, and the handle and its origin carry across. A redirect the * {@link ConnectionProps.goaway} policy refuses stops the loop like an auth rejection does. * - * Options the pool cannot honor (transport options, delay, timeout, goaway, a pinned + * Options the pool cannot honor (transport options, delay, timeout, goaway, tokens, a pinned * certificate, caller-owned origins) use a private loop. An explicit `share: true` * refuses them. A supplied transport cannot reconnect at all; pass it to {@link Connection.connect} * instead. @@ -310,6 +316,7 @@ export class Connection { delay: props.delay, timeout: props.timeout, goaway: props.goaway, + tokens: props.tokens, }); signals.cleanup(() => loop.close()); @@ -448,6 +455,9 @@ function refuse(props?: ConnectionProps): void { if (props.delay !== undefined) { throw new Error("delay cannot be shared; pass share: false"); } + if (props.tokens !== undefined) { + throw new Error("tokens cannot be shared; pass share: false"); + } if (props.timeout !== undefined) { throw new Error("timeout cannot be shared; pass share: false"); } @@ -462,6 +472,7 @@ function requiresPrivate(props: ConnectionProps): boolean { props.webtransport !== undefined || props.websocket !== undefined || props.delay !== undefined || + props.tokens !== undefined || props.timeout !== undefined || props.goaway !== undefined || props.publish !== undefined || diff --git a/js/net/src/connection/reload.test.ts b/js/net/src/connection/reload.test.ts index febd5649673..925cf35c045 100644 --- a/js/net/src/connection/reload.test.ts +++ b/js/net/src/connection/reload.test.ts @@ -402,6 +402,80 @@ for (const code of [SessionCode.Unauthorized, SessionCode.MalformedAuthToken, Se }); } +/** + * Stub WebTransport for lite-06, which carries the credential in the URL: refuses `refused` + * once `release` resolves, and accepts any other credential, recording each dialed jwt. + */ +function refusing(refused: string, release: Promise) { + const dialed: (string | null)[] = []; + const stub = function StubWebTransport(target: string | URL) { + const jwt = new URL(target).searchParams.get("jwt"); + dialed.push(jwt); + const pair = createMockTransportPair(Lite.ALPN_06); + void accept(pair.server, new URL(target)).then(async () => { + if (jwt !== refused) return; + await release; + pair.server.close({ closeCode: SessionCode.Unauthorized, reason: "unauthorized" }); + }); + return pair.client; + }; + return { dialed, stub: stub as unknown as typeof WebTransport }; +} + +test("a refused credential replaced since its dial redials with the new one", async () => { + const original = globalThis.WebTransport; + const { promise: release, resolve } = Promise.withResolvers(); + const { dialed, stub } = refusing("old", release); + globalThis.WebTransport = stub; + + const tokens = new Signal(["old"]); + const reload = new Reload({ + url: new URL("https://example.com/"), + versions: ["moq-lite-06"], + websocket: { enabled: false }, + tokens, + delay: { initial: Milli(1), multiplier: 2, max: Milli(1), timeout: Milli(0) }, + }); + try { + await waitUntil(() => reload.established.peek() !== undefined); + tokens.set(["new"]); + resolve(); + await waitUntil(() => dialed.includes("new")); + expect(dialed).toEqual(["old", "new"]); + await waitUntil(() => reload.established.peek() !== undefined); + expect(reload.error.peek()).toBeUndefined(); + } finally { + reload.close(); + globalThis.WebTransport = original; + } +}); + +test("new tokens redial a URL that stopped on refusing the old ones", async () => { + const original = globalThis.WebTransport; + const { dialed, stub } = refusing("old", Promise.resolve()); + globalThis.WebTransport = stub; + + const tokens = new Signal(["old"]); + const reload = new Reload({ + url: new URL("https://example.com/"), + versions: ["moq-lite-06"], + websocket: { enabled: false }, + tokens, + delay: { initial: Milli(1), multiplier: 2, max: Milli(1), timeout: Milli(0) }, + }); + try { + await waitUntil(() => reload.error.peek() !== undefined); + expect(dialed).toEqual(["old"]); + tokens.set(["new"]); + await waitUntil(() => dialed.includes("new")); + await waitUntil(() => reload.established.peek() !== undefined); + expect(reload.error.peek()).toBeUndefined(); + } finally { + reload.close(); + globalThis.WebTransport = original; + } +}); + test("an unauthorized session close during setup stops retrying", async () => { const original = globalThis.WebTransport; const url = new URL("https://example.com/"); diff --git a/js/net/src/connection/reload.ts b/js/net/src/connection/reload.ts index 7bdb9cee285..ab40e3c824c 100644 --- a/js/net/src/connection/reload.ts +++ b/js/net/src/connection/reload.ts @@ -20,6 +20,11 @@ import type { Established } from "./established.ts"; import { DISCOVERY_REMOVED } from "./forward.ts"; import { DEFAULT_HANDOVER, type Drain, dialed, type GoawayProps, handover, pinnedTransport, target } from "./goaway.ts"; import type { Probe, Stats } from "./stats.ts"; +import { heldOf } from "./tokens.ts"; + +function sameTokens(a: readonly string[], b: readonly string[]): boolean { + return a.length === b.length && a.every((token, i) => token === b[i]); +} /** * Exponential backoff settings for the reconnect loop. @@ -76,7 +81,13 @@ export type ReloadDelay = { * * @internal */ -export type ReloadProps = Omit & { +export type ReloadProps = Omit< + ConnectProps, + "url" | "signal" | "transport" | "implementation" | "extensions" | "tokens" +> & { + /** See {@link ConnectProps.tokens}; pass a `Signal` to replace them before they expire. */ + tokens?: readonly string[] | Signal; + /** A reload owns the abort signal for each connection attempt. */ signal?: never; @@ -170,6 +181,14 @@ export class Reload { /** Whether reconnecting is active. */ enabled: Signal; + /** + * Credentials every dial presents; see {@link ConnectProps.tokens}. Replacing them reaches + * the next dial and, on a live session with AUTH, presents the new tokens at once, + * withdrawing the replaced ones once every new one has its answer. A session refused as + * unauthorized redials only when these changed since its dial. + */ + tokens: Signal; + /** Current connection status. */ status = new Signal("disconnected"); @@ -309,6 +328,11 @@ export class Reload { // loop is disabled. #givenUpHref: string | undefined; + // The tokens the latest dial presented, so a refusal can tell whether they changed since. + #dialedTokens: readonly string[] = []; + // Whether the sequence stopped because the peer refused those tokens. + #refused = false; + // Increased by 1 each time to trigger a reload. #tick = new Signal(0); @@ -324,6 +348,7 @@ export class Reload { if (props && "discovery" in props) throw new TypeError(DISCOVERY_REMOVED); this.url = Signal.from(props?.url); this.enabled = Signal.from(props?.enabled ?? true); + this.tokens = Signal.from(props?.tokens ?? []); this.delay = props?.delay ?? {}; // Validated here, like the timeout, rather than by every retry. pacing(this.delay); @@ -406,6 +431,23 @@ export class Reload { effect.interval(() => void sample(), BANDWIDTH_POLL); }); + // The live session holds the current tokens on AUTH; a dial picks them up itself. + this.#signals.run((effect) => { + const tokens = effect.get(this.tokens); + const connection = effect.get(this.established); + if (connection) heldOf(connection)?.update(tokens); + }); + + // New credentials redial a URL that stopped on an auth refusal of the old ones. + this.#signals.run((effect) => { + const tokens = effect.get(this.tokens); + if (!this.#refused || this.#givenUpHref === undefined || sameTokens(tokens, this.#dialedTokens)) return; + this.#refused = false; + this.#givenUpHref = undefined; + this.#error.set(undefined); + this.#tick.update((tick) => tick + 1); + }); + this.#url = this.#signals.computed((effect) => effect.get(this.url)?.href); // Create a reactive root so cleanup is easier. this.#signals.run(this.#connect.bind(this)); @@ -486,10 +528,12 @@ export class Reload { this.#deadline ??= this.#window(now()); this.#dialing = true; + this.#dialedTokens = this.tokens.peek(); let connection: Established; try { connection = await connect({ url: dialing, + tokens: this.#dialedTokens, // A redirect names the relay; a fallback URL pinned for the old one does not follow. websocket: this.#redirect.peek() ? { ...this.websocket, url: undefined } : this.websocket, versions: this.versions, @@ -725,14 +769,18 @@ export class Reload { // Only a session close says that. The stream registry gives 2 to DELIVERY_TIMEOUT, // so a stream reset during the SETUP exchange would otherwise suppress reconnect // for good. + // + // Tokens replaced since that dial are new credentials, so those earn another dial. if ( cause instanceof SessionError && (cause.code === SessionCode.Unauthorized || cause.code === SessionCode.MalformedAuthToken || - cause.code === SessionCode.Expired) + cause.code === SessionCode.Expired) && + sameTokens(this.tokens.peek(), this.#dialedTokens) ) { console.warn("session rejected as unauthorized, not retrying"); this.#giveUp(cause); + this.#refused = true; return; } @@ -761,6 +809,7 @@ export class Reload { } #giveUp(cause: Error): void { + this.#refused = false; this.#error.set(cause); this.#givenUpHref = this.#sequenceHref; this.#resetSequence(); diff --git a/js/net/src/connection/tokens.test.ts b/js/net/src/connection/tokens.test.ts new file mode 100644 index 00000000000..4faa1c0b09d --- /dev/null +++ b/js/net/src/connection/tokens.test.ts @@ -0,0 +1,134 @@ +import { expect, test } from "bun:test"; +import type { Auth, Handle } from "../auth.ts"; +import * as Ietf from "../ietf/index.ts"; +import { createMockTransportPair } from "../mock.ts"; +import { Reader } from "../stream.ts"; +import type { Established } from "./established.ts"; +import { offered } from "./extensions.ts"; +import { exchangeSetup } from "./handshake.ts"; +import { credential, extra, fallback, Held, hold } from "./tokens.ts"; + +const URL_ = new URL("https://relay.example.com/room"); + +test("versions with a SETUP token keep the credential out of the URL", () => { + const dial = credential(URL_, ["a", "b"], true); + expect(dial.url.href).toBe(URL_.href); + expect(dial.setup).toBe("a"); + expect(extra(dial, ["a", "b", "b"])).toEqual(["b"]); +}); + +test("an older offered version puts the credential in the URL too", () => { + const dial = credential(new URL("https://relay.example.com/room?x=1"), ["a b"], false); + expect(dial.url.href).toBe("https://relay.example.com/room?x=1&jwt=a+b"); + expect(dial.setup).toBe("a b"); + expect(extra(dial, ["a b"])).toEqual([]); +}); + +test("a URL credential sends every other token on AUTH", () => { + const url = new URL("https://relay.example.com/room?jwt=peer"); + const dial = credential(url, ["a", "peer"], false); + expect(dial.url.href).toBe(url.href); + expect(dial.setup).toBeUndefined(); + expect(extra(dial, ["a", "peer"])).toEqual(["a"]); +}); + +test("a WebSocket fallback presents the credential its own URL decides", () => { + const ws = new URL("wss://ws.example.com/room"); + // Its own `jwt` is its connection credential, so the configured token rides AUTH, not SETUP. + const own = fallback(URL_, new URL("wss://ws.example.com/room?jwt=b"), ["a"], true); + expect(own.setup).toBeUndefined(); + expect(extra(own, ["a"])).toEqual(["a"]); + // Without one it inherits the primary URL's, or else takes the configured tokens. + expect(fallback(new URL("https://relay.example.com/room?jwt=p"), ws, ["a"], true).url.href).toBe( + `${ws.href}?jwt=p`, + ); + const configured = fallback(URL_, ws, ["a"], false); + expect(configured.url.href).toBe(`${ws.href}?jwt=a`); + expect(configured.setup).toBe("a"); +}); + +test("no tokens present nothing", () => { + const dial = credential(URL_, [], false); + expect(dial.url.href).toBe(URL_.href); + expect(dial.setup).toBeUndefined(); +}); + +/** A handle whose answers the test releases one token at a time. */ +function handle() { + const answers = new Map void>(); + const closed: string[] = []; + const presented: string[] = []; + const fake = { + add(token: string) { + presented.push(token); + return new Promise((resolve) => answers.set(token, resolve)); + }, + } as unknown as Handle; + const answer = async (token: string) => { + const resolve = answers.get(token); + if (!resolve) throw new Error(`${token} was never presented`); + resolve({ close: () => closed.push(token) } as unknown as Auth); + // Let the answer and the settle it schedules run. + for (let i = 0; i < 3; i++) await Promise.resolve(); + }; + return { fake, answers, closed, presented, answer }; +} + +test("a replaced token is withdrawn only once its replacement answers", async () => { + const { fake, answers, closed, answer } = handle(); + const held = new Held(fake, credential(URL_, ["connection"], true)); + held.update(["connection", "old"]); + expect([...answers.keys()]).toEqual(["old"]); + await answer("old"); + + held.update(["connection", "new"]); + expect([...answers.keys()]).toEqual(["old", "new"]); + expect(closed).toEqual([]); + + await answer("new"); + expect(closed).toEqual(["old"]); +}); + +test("a token replaced before its answer closes when it arrives", async () => { + const { fake, closed, answer } = handle(); + const held = new Held(fake, credential(URL_, ["connection"], true)); + held.update(["connection", "old"]); + held.update(["connection", "new"]); + await answer("new"); + expect(closed).toEqual([]); + await answer("old"); + expect(closed).toEqual(["old"]); +}); + +test("sessions sharing an auth handle present each configured token once", async () => { + const { fake, closed, presented, answer } = handle(); + const first = { auth: fake } as unknown as Established; + const second = { auth: fake } as unknown as Established; + hold(first, credential(URL_, ["connection", "a"], true), ["connection", "a"]); + await answer("a"); + + // A reconnect on the same handle, whose credential now comes from the URL. + const url = new URL("https://relay.example.com/room?jwt=peer"); + hold(second, credential(url, ["connection", "a"], true), ["connection", "a"]); + expect(presented).toEqual(["a", "connection"]); + expect(closed).toEqual([]); +}); + +test("a draft-17+ client presents its token in SETUP", async () => { + const version = Ietf.Version.DRAFT_18; + const pair = createMockTransportPair(Ietf.ALPN.DRAFT_18); + const token = new TextEncoder().encode("credential"); + // Never answered: only the SETUP the client writes matters here. + void exchangeSetup(pair.client, version, "test", { client: true, extensions: offered(), token }).catch(() => {}); + + const streams = pair.server.incomingUnidirectionalStreams.getReader(); + const next = await streams.read(); + if (next.done) throw new Error("no SETUP stream"); + const reader = new Reader(next.value, undefined, version); + expect(await reader.u53()).toBe(Ietf.Setup.id); + const setup = await Ietf.Setup.decode(reader, version); + const presented = Ietf.tokenFromSetup(setup.parameters, version); + expect(presented?.kind).toBe(Ietf.TOKEN_OUT_OF_BAND); + expect(new TextDecoder().decode(presented?.value)).toBe("credential"); + pair.client.close(); +}); diff --git a/js/net/src/connection/tokens.ts b/js/net/src/connection/tokens.ts new file mode 100644 index 00000000000..5728c525cae --- /dev/null +++ b/js/net/src/connection/tokens.ts @@ -0,0 +1,157 @@ +import { type Auth, type Handle, Unsupported } from "../auth.ts"; +import type { Established } from "./established.ts"; + +/** What one dial presents, decided from its URL, the offered versions, and the tokens. @internal */ +export interface Credential { + /** The URL to dial, carrying the connection credential when an offered version has no SETUP token. */ + url: URL; + /** The token SETUP carries, unless the URL brought its own credential. */ + setup?: string; + /** The credential the connection presents, from the URL or SETUP. Never also presented on AUTH. */ + connection?: string; +} + +/** + * Decide what one dial presents. + * + * A URL that already carries a `jwt` keeps it (a per-peer credential), so every token rides + * AUTH. Otherwise the first token is the connection credential: it rides SETUP, and the URL + * too when `setupToken` is false, since the URL is fixed before the version is negotiated. + * + * @internal + */ +export function credential(url: URL, tokens: readonly string[], setupToken: boolean): Credential { + const jwt = url.searchParams.get("jwt"); + if (jwt) return { url, connection: jwt }; + const first = tokens[0]; + if (first === undefined) return { url }; + return { url: setupToken ? url : withJwt(url, first), setup: first, connection: first }; +} + +/** + * Decide what a dial to the WebSocket fallback's own URL presents. + * + * The fallback presents the credential its URL decides, never one taken from the primary: + * its own `jwt`, else the primary URL's `jwt`, else the configured tokens as {@link credential} + * decides them. + * + * @internal + */ +export function fallback(primary: URL, url: URL, tokens: readonly string[], setupToken: boolean): Credential { + const inherited = primary.searchParams.get("jwt"); + if (inherited && !url.searchParams.get("jwt")) return credential(withJwt(url, inherited), tokens, setupToken); + return credential(url, tokens, setupToken); +} + +/** `url` with `jwt` appended to its query. @internal */ +export function withJwt(url: URL, jwt: string): URL { + const next = new URL(url); + next.searchParams.append("jwt", jwt); + return next; +} + +/** The tokens to hold on their own AUTH streams, each once. @internal */ +export function extra(credential: Credential, tokens: readonly string[]): string[] { + return [...new Set(tokens)].filter((token) => token !== credential.connection); +} + +interface Entry { + auth?: Auth; + pending: boolean; + retired: boolean; +} + +/** + * The configured tokens an auth handle holds on their own AUTH streams, beside the + * latest dial's connection credential. A handle shared across reconnects keeps them on + * every session it backs. + * + * A change presents the new tokens at once and withdraws the ones they replace only after + * every new one has its answer, so the grant never narrows in between. + * + * @internal + */ +export class Held { + #handle: Handle; + #credential: Credential; + #entries = new Map(); + #retiring: Entry[] = []; + + constructor(handle: Handle, credential: Credential) { + this.#handle = handle; + this.#credential = credential; + } + + /** A new dial on the handle, whose credential decides which tokens ride AUTH. */ + dialed(credential: Credential, tokens: readonly string[]) { + this.#credential = credential; + this.update(tokens); + } + + /** Hold exactly `tokens`, other than the connection credential. */ + update(tokens: readonly string[]) { + const next = new Map(); + for (const token of extra(this.#credential, tokens)) { + next.set(token, this.#entries.get(token) ?? this.#present(token)); + } + for (const [token, entry] of this.#entries) { + if (!next.has(token)) this.#retiring.push(entry); + } + this.#entries = next; + this.#settle(); + } + + #present(token: string): Entry { + const entry: Entry = { pending: true, retired: false }; + // Registered synchronously, so a request sent right after waits on its answer. + this.#handle + .add(token) + .then( + (auth) => { + entry.auth = auth; + // Replaced while waiting, and every replacement has answered since. + if (entry.retired) auth.close(); + }, + (err: unknown) => { + if (err instanceof Unsupported) console.debug("the peer takes no tokens in band"); + else console.warn("a configured token was refused:", err); + }, + ) + .finally(() => { + entry.pending = false; + this.#settle(); + }); + return entry; + } + + #settle() { + if ([...this.#entries.values()].some((entry) => entry.pending)) return; + for (const entry of this.#retiring) { + entry.retired = true; + entry.auth?.close(); + } + this.#retiring = []; + } +} + +// The tokens each auth handle holds, so a handle shared across reconnects holds them once +// and a reconnecting wrapper can update them. +const held = new WeakMap(); + +/** Present `tokens` on `connection`, beside the credential its dial carried. @internal */ +export function hold(connection: Established, credential: Credential, tokens: readonly string[]): Held { + const existing = held.get(connection.auth); + if (existing) { + existing.dialed(credential, tokens); + return existing; + } + const tokensHeld = new Held(connection.auth, credential); + tokensHeld.update(tokens); + held.set(connection.auth, tokensHeld); + return tokensHeld; +} + +/** The tokens `connection` holds, if it was dialed with any. @internal */ +export function heldOf(connection: Established): Held | undefined { + return held.get(connection.auth); +} diff --git a/js/net/src/lite/connection.ts b/js/net/src/lite/connection.ts index 206d27efa94..f1842f4871a 100644 --- a/js/net/src/lite/connection.ts +++ b/js/net/src/lite/connection.ts @@ -67,6 +67,8 @@ export interface ConnectionProps { * Defaults to true. */ client?: boolean; + /** The connection credential the client's SETUP presents, on versions that carry one (lite-07+). */ + token?: Uint8Array; /** The implementation our SETUP advertises (lite-05+). Defaults to {@link IMPLEMENTATION}; empty sends nothing. */ implementation?: string; } @@ -127,6 +129,8 @@ export class Connection implements Established { // Whether we dialed; see {@link ConnectionProps.client}. #client: boolean; + // See {@link ConnectionProps.token}. + #token?: Uint8Array; // What our SETUP advertises; see {@link ConnectionProps.implementation}. #implementation: string; #peerImplementation = Promise.withResolvers(); @@ -163,9 +167,12 @@ export class Connection implements Established { publish, handle, client = true, + token, implementation = IMPLEMENTATION, }: ConnectionProps) { this.url = url; + // Older versions carry the credential in the URL instead. + this.#token = client && hasAuth(version) ? token : undefined; this.#quic = quic; this.#session = session; // The session stream was opened at the SETUP exchange's version; the rest of it speaks this one. @@ -342,7 +349,7 @@ export class Connection implements Established { try { await writer.u53(DataType.Setup); const probe = await probeLevel(this.#quic, this.#version); - await new Setup({ probe, hop: this.hop, implementation: this.#implementation }).encode( + await new Setup({ probe, hop: this.hop, token: this.#token, implementation: this.#implementation }).encode( writer, this.#version, ); @@ -465,7 +472,7 @@ export class Connection implements Established { throw new ProtocolViolation("invalid SETUP", { cause: err }); }); if (this.#client && hasClientParameters(setup)) - throw new ProtocolViolation("server SETUP contains Path or Role"); + throw new ProtocolViolation("server SETUP contains Path, Role, or Token"); if (!this.#client && setup.path !== undefined) throw new ProtocolViolation("Path on a URI-carrying binding"); this.#peerSetup.set(setup); diff --git a/js/net/src/lite/setup.test.ts b/js/net/src/lite/setup.test.ts index 0442a813467..ecfd10196d3 100644 --- a/js/net/src/lite/setup.test.ts +++ b/js/net/src/lite/setup.test.ts @@ -204,3 +204,26 @@ test("SETUP decode refuses an oversized length before reading the body", async ( "too large", ); }); + +const PARAM_TOKEN = 0x7; + +test("SETUP token round-trips on lite-07 only", async () => { + const msg = new Setup({ token: new TextEncoder().encode("jwt") }); + const wire = await bytes((w) => msg.encode(w, Version.DRAFT_07), Version.DRAFT_07); + // Length, one parameter, its id, then the token's length and bytes. + expect([...wire]).toEqual([0x06, 0x01, PARAM_TOKEN, 0x03, 0x6a, 0x77, 0x74]); + const got = await Setup.decode(new Reader(undefined, wire, Version.DRAFT_07), Version.DRAFT_07); + expect(new TextDecoder().decode(got.token)).toBe("jwt"); + + await expect(bytes((w) => msg.encode(w, Version.DRAFT_06), Version.DRAFT_06)).rejects.toThrow(); + // An older version ignores the parameter like any unknown one. + const older = await Setup.decode(new Reader(undefined, wire, Version.DRAFT_06), Version.DRAFT_06); + expect(older.token).toBeUndefined(); +}); + +test("SETUP refuses an empty token", async () => { + const empty = new Setup({ token: new Uint8Array() }); + await expect(bytes((w) => empty.encode(w, Version.DRAFT_07), Version.DRAFT_07)).rejects.toThrow(); + const wire = new Uint8Array([0x03, 0x01, PARAM_TOKEN, 0x00]); + await expect(Setup.decode(new Reader(undefined, wire, Version.DRAFT_07), Version.DRAFT_07)).rejects.toThrow(); +}); diff --git a/js/net/src/lite/setup.ts b/js/net/src/lite/setup.ts index 7db294127ff..b50131be1e0 100644 --- a/js/net/src/lite/setup.ts +++ b/js/net/src/lite/setup.ts @@ -9,11 +9,11 @@ import { type Hop, HopSchema, wireHop } from "../hop.ts"; import { encodeVarint, Reader, type Writer } from "../stream.ts"; import { decodeUtf8 } from "../util/utf8.ts"; import * as Message from "./message.ts"; -import { hasSetupStream, type Version } from "./version.ts"; +import { hasAuth, hasSetupStream, type Version } from "./version.ts"; const clientParameters = new WeakSet(); -/** Whether the peer's SETUP carried Path or Role; raw presence matters even when an unknown Role decodes as Both. */ +/** Whether the peer's SETUP carried Path, Role, or Token; raw presence matters even when an unknown Role decodes as Both. */ export function hasClientParameters(setup: Setup): boolean { return clientParameters.has(setup); } @@ -28,6 +28,8 @@ const PARAM_ROLE = 0x3n; const PARAM_HOP = 0x5n; /** Setup Parameter id for the endpoint's implementation name and version. */ const PARAM_IMPLEMENTATION = 0x6n; +/** Setup Parameter id for the client's connection credential (client-only, lite-07+). */ +const PARAM_TOKEN = 0x7n; /** Cap on the number of parameters in a bag, matching the Rust decoder. */ const MAX_PARAMS = 64; @@ -184,6 +186,8 @@ export interface SetupProps { role?: Role; /** See {@link Setup.hop}. Omitted by default. */ hop?: Hop; + /** See {@link Setup.token}. Omitted by default. */ + token?: Uint8Array; /** See {@link Setup.implementation}. Omitted by default. */ implementation?: string; } @@ -223,17 +227,24 @@ export class Setup { */ hop?: Hop; + /** + * The connection credential (lite-07+), which the empty-token Auth Stream names. Sent + * only by the client. Never empty: an empty value is no credential. + */ + token?: Uint8Array; + /** * This endpoint's implementation and version, such as `moq-net-js/0.4.2`. Opaque and * peer-controlled, so only for logs and rollout counts. `undefined` or empty sends nothing. */ implementation?: string; - constructor({ probe, path, role, hop, implementation }: SetupProps = {}) { + constructor({ probe, path, role, hop, token, implementation }: SetupProps = {}) { this.probe = probe ?? ProbeLevel.None; this.path = path; this.role = role ?? Role.Both; this.hop = hop; + this.token = token; this.implementation = implementation; } @@ -262,6 +273,11 @@ export class Setup { if (this.implementation) { params.setBytes(PARAM_IMPLEMENTATION, new TextEncoder().encode(this.implementation)); } + if (this.token !== undefined) { + if (!hasAuth(version)) throw new Error("setup token not supported for this version"); + if (this.token.byteLength === 0) throw new Error("setup token cannot be empty"); + params.setBytes(PARAM_TOKEN, this.token); + } await params.encode(w); } @@ -283,11 +299,15 @@ export class Setup { const hopRaw = await params.getVarint(PARAM_HOP, version); const hop = hopRaw === undefined || hopRaw === 0n ? undefined : HopSchema.parse(hopRaw); + // An older version does not know the token parameter, so it ignores it like any other. + const token = hasAuth(version) ? params.getBytes(PARAM_TOKEN) : undefined; + if (token?.byteLength === 0) throw new Error("setup token cannot be empty"); + const implementationBytes = params.getBytes(PARAM_IMPLEMENTATION); const implementation = implementationBytes === undefined ? undefined : decodeUtf8(implementationBytes); - const setup = new Setup({ probe, path, role, hop, implementation }); - if (pathBytes !== undefined || roleCode !== undefined) clientParameters.add(setup); + const setup = new Setup({ probe, path, role, hop, token, implementation }); + if (pathBytes !== undefined || roleCode !== undefined || token !== undefined) clientParameters.add(setup); return setup; } diff --git a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt index b10eba5aa94..18ab27e8460 100644 --- a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt +++ b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt @@ -11,8 +11,10 @@ package dev.moq // listen facade (see Server.kt), which exposes the raw handle as `server`. /** A MoQ client built from a [ClientConfig]: connect it to a relay. */ typealias Client = uniffi.moq.MoqClient -/** Client configuration: bind address, versions, TLS, QUIC, WebSocket, reconnect pacing, and origins. */ +/** Client configuration: bind address, versions, tokens, TLS, QUIC, WebSocket, reconnect pacing, and origins. */ typealias ClientConfig = uniffi.moq.MoqClientConfig +/** The credentials a [Client] presents, replaceable while it runs. */ +typealias Tokens = uniffi.moq.MoqTokens /** Certificate trust and the mTLS identity for a [ClientConfig]. */ typealias ClientTls = uniffi.moq.MoqClientTls /** Server configuration: bind address, versions, TLS identity, QUIC, and origins. */ diff --git a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Moq.kt b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Moq.kt index 2b30d0c7acd..c8e6878a575 100644 --- a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Moq.kt +++ b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Moq.kt @@ -68,6 +68,17 @@ class Moq internal constructor( */ fun auth(): Auth = session.auth() + /** + * The credentials every dial presents ([ClientConfig.tokens]), to replace while running. + * + * [Tokens.set] replaces them, such as before the current ones expire: the next dial presents + * them, and a live session with in-band AUTH presents them at once, withdrawing each + * replaced token once its replacements are answered, even when one is refused. After an + * authorization refusal the client redials only if they changed. An empty token throws + * `MoqException.Config`. + */ + fun tokens(): Tokens = client.tokens() + /** * Request a draining close now; await `session.closed()` for its result, then [close] * to release the client. diff --git a/py/moq-rs/moq/__init__.py b/py/moq-rs/moq/__init__.py index 444da326c93..0413194853f 100644 --- a/py/moq-rs/moq/__init__.py +++ b/py/moq-rs/moq/__init__.py @@ -11,7 +11,7 @@ from . import flate as flate from . import json as json from . import video as video -from .client import Client, CongestionControl, connect, qlog_supported, versions +from .client import Client, CongestionControl, Tokens, connect, qlog_supported, versions from .epoch import mint_epoch from .errors import is_auth, is_shutdown, protocol_error from .log import log_level @@ -132,6 +132,7 @@ "Server", "Session", "Subscription", + "Tokens", "TrackConsumer", "TrackDemand", "TrackDynamic", diff --git a/py/moq-rs/moq/client.py b/py/moq-rs/moq/client.py index 7ad637edd6d..f79a3c35b21 100644 --- a/py/moq-rs/moq/client.py +++ b/py/moq-rs/moq/client.py @@ -32,6 +32,25 @@ def qlog_supported() -> bool: return moq_qlog_supported() +class Tokens: + """The credentials a :class:`Client` presents, replaceable while it runs.""" + + def __init__(self, client: Client) -> None: + self._client = client + + def set(self, tokens: Sequence[str]) -> None: + """Replace the credentials every dial presents, such as before the current ones expire. + + The next dial presents them, and a live session with in-band AUTH presents them at + once, withdrawing each replaced token once its replacements are answered, even when + one is refused. After an authorization refusal the client redials only if they changed. + """ + tokens = _strs(tokens, "tokens") + if self._client._inner is not None: + self._client._inner.tokens().set(tokens) + self._client._config.tokens = tokens + + class Client: """High-level MoQ client with automatic origin wiring. @@ -65,6 +84,12 @@ class Client: ``versions`` restricts the protocol versions offered, most preferred first, spelled like ``"moq-lite-03"``; empty offers every supported version, which :func:`versions` lists. + ``tokens`` are credentials every dial presents. The first is the connection credential, + sent in SETUP and, while an offered version can't carry it there, the URL's ``?jwt=``; + each other one widens the session's grant over in-band AUTH. A URL that already carries + ``?jwt=`` keeps it, and then every token rides AUTH. Replace them before they expire + through :meth:`tokens`. + ``timeout`` bounds one connect, dial and handshake together (zero waits forever). ``race`` and ``resolution_delay`` pace Happy Eyeballs. ``tls`` sets certificate trust and the mTLS identity (:class:`ClientTls`), and ``quic`` tunes the transport @@ -77,6 +102,7 @@ def __init__( *, bind: str | None = None, versions: Sequence[str] = (), + tokens: Sequence[str] = (), timeout: timedelta | None = None, race: timedelta | None = None, resolution_delay: timedelta | None = None, @@ -94,6 +120,7 @@ def __init__( self._config = MoqClientConfig( bind=bind, versions=_strs(versions, "versions"), + tokens=_strs(tokens, "tokens"), timeout=timeout, race=race, resolution_delay=resolution_delay, @@ -148,6 +175,10 @@ async def __aexit__(self, *exc) -> None: self._inner.cancel() self._inner = None + def tokens(self) -> Tokens: + """The credentials every dial presents, to replace while running.""" + return Tokens(self) + def create_broadcast(self, path: str) -> BroadcastProducer: """Create an unannounced broadcast at ``path``, invisible until announced. Announce it after populating tracks. @@ -194,6 +225,7 @@ def connect( *, bind: str | None = None, versions: Sequence[str] = (), + tokens: Sequence[str] = (), timeout: timedelta | None = None, race: timedelta | None = None, resolution_delay: timedelta | None = None, @@ -216,6 +248,7 @@ def connect( url, bind=bind, versions=versions, + tokens=tokens, timeout=timeout, race=race, resolution_delay=resolution_delay, diff --git a/py/moq-rs/tests/test_local.py b/py/moq-rs/tests/test_local.py index b7b17e0cd60..12ea0dd07d2 100644 --- a/py/moq-rs/tests/test_local.py +++ b/py/moq-rs/tests/test_local.py @@ -902,6 +902,14 @@ def test_public_api_exports(): assert client._config.tls.fingerprints == ["abc123"] +def test_client_tokens_before_connecting(): + """Tokens reach the config, and setting them before connecting replaces them there.""" + client = moq.connect("https://example.com", tokens=["first", "second"]) + assert client._config.tokens == ["first", "second"] + client.tokens().set(["fresh"]) + assert client._config.tokens == ["fresh"] + + async def test_container_consumer_default_latency_and_context_manager(): """TrackConsumer.subscribe defaults the latency; the returned consumer is also an async context manager.""" diff --git a/quest/m1/api-audit/README.md b/quest/m1/api-audit/README.md index 955517b3214..58e0c84b782 100644 --- a/quest/m1/api-audit/README.md +++ b/quest/m1/api-audit/README.md @@ -56,9 +56,6 @@ audit skips them: - `setup::Extensions::{auth, solicit, epoch}` and the relay's `[listen|connect.extensions]` TOML, as interop knobs documented as affecting moq-transport only. -- `setup::Token` and `server::Handshake::token`: - [token in band](/quest/m1/auth/token-in-band.md) gives lite-07 a SETUP - token, which makes them shared. Rejected in planning: diff --git a/quest/m1/auth/README.md b/quest/m1/auth/README.md index 65bb8c2ee58..4e1bfadb816 100644 --- a/quest/m1/auth/README.md +++ b/quest/m1/auth/README.md @@ -144,9 +144,6 @@ because `main` assigned 0x3A to NOT_FETCHABLE first. ## Required -- [Token in band](/quest/m1/auth/token-in-band.md) - extra credentials ride - AUTH streams: a session starts on the token its SETUP or URL carried and its - AUTH streams add the rest - [Binding auth polish](/quest/m1/auth/bindings-polish.md) - OBS reconnects with the presented token, Swift grant sequences ## Related diff --git a/quest/m1/auth/bindings-polish.md b/quest/m1/auth/bindings-polish.md index 15f1aff2c81..4101abd70bd 100644 --- a/quest/m1/auth/bindings-polish.md +++ b/quest/m1/auth/bindings-polish.md @@ -18,7 +18,3 @@ Decided 2026-10-21 with the maintainer: one m1 quest. forcing it. Public API: Swift additive. Wire: none. - -## Required - -- [Token in band](/quest/m1/auth/token-in-band.md) - tokens that ride SETUP, which the OBS reconnect needs diff --git a/quest/m1/auth/token-in-band.md b/quest/m1/auth/token-in-band.md deleted file mode 100644 index 260325e493e..00000000000 --- a/quest/m1/auth/token-in-band.md +++ /dev/null @@ -1,88 +0,0 @@ -# [M] Token in band - -## Goal - -Several credentials can ride one connection. Every client in this repository -takes tokens as configuration separate from the address, puts the first in -its SETUP (the URL query when the negotiated version has no SETUP token), -presents each other one on its own AUTH stream at setup, and the relay admits -the session on that first token and widens it as the streams are answered. Peers without the AUTH stream keep -working unchanged, so nothing is refused and nothing goes silent, and a -session's first requests wait on no AUTH. - -## Plan - -IETF AUTH can refuse a grant too large for one AUTH_OK. Propagate its -`Unsupported` result; do not retry the token through the URL, trim it, or -leave token admission pending. - -- Client configuration separates the credential from the address: `moq_tokio::connect::Config` gains `tokens`, repeatable as - `--connect-token` and `MOQ_CONNECT_TOKEN`, the default set for every dial - the client makes. A `?jwt=` in the URL stays a member of the union, which is - how cluster dial targets keep their per-peer credential, and per-dial - extras use the session's `auth().add()` once connected. moq-ffi gains a - `tokens` field on the `MoqClientConfig` record that - the FFI shape (#4697) put in place of every - `MoqClient` setter, mirroring `moq_tokio::connect::Config`, not a new - `set_tokens` setter (mirrored in the wrappers, and reaching the - generated C and C++ bindings and OBS through the generated C++) and `js/net`'s `connect` - options field follow. Decided in the 2026-09-30 audit: no new libmoq API, - since it gets no more feature work. -- Credential refresh belongs with token presentation. Resolve the configured - credential source before each dial. After an authorization refusal, resolve - it once more and retry only when the credential changed; do not loop on a - rejected credential. On an AUTH-capable session, replace a token through - its own AUTH stream so the accepted grant union remains live. Keep `?jwt=` - as the fallback for peers without AUTH. Choose the public credential-source - API while implementing this quest. -- Presenting: ✅ Decided 2026-10-19 (maintainer, - [#5972](https://github.com/moq-dev/moq/pull/5972)): the first configured - token rides SETUP. lite-07-wip gains a SETUP token parameter, filling - `server::Handshake::token` the way moq-transport's AUTHORIZATION TOKEN - setup option (`ietf::token::into_setup`, `USE_VALUE`, token type 0) already - does, so the relay hands it to the auth server at accept. It costs no round - trip: the acceptor already reads the client's SETUP before serving, and a - lite client sends requests without waiting on the server's SETUP. The - version is negotiated (ALPN or the WebTransport subprotocol) before SETUP - is written, but the URL is fixed before negotiation, so the client also - copies the first token into the URL query while any offered version lacks - a SETUP token (every lite version before 07). On raw QUIC and Qmux the - query already rides SETUP's `Path`. A dial URL that already carries a - credential (a cluster's per-peer `?jwt=`) keeps it as the connection - credential instead, and then every configured token gets its own AUTH - stream: the client never appends a second `jwt` or sends a different SETUP - token. A token that went into the URL or SETUP is the connection - credential, and the empty-token stream is its one and only stream; every - other configured token gets its own AUTH stream on an AUTH-capable session, - so no token is ever granted twice. While the URL also carries it, the auth - server sees the same credential twice: `moq auth serve` admits a SETUP - token equal to the `?jwt=` value and refuses two different ones - (`serve::Denial::TwoTokens`). Never send different values in the two - places. A token in SETUP is bounded by SETUP's Message Length cap and - reaches the auth server exactly as a URL token does, so an unauthenticated - client gains nothing it could not already send in the URL. Dropped: the - earlier "always in the CONNECT request" rule and its auth-header plan. -- The relay admits on the URL, then widens. An anonymous connection is - admitted with the public grant when one is configured and refused at - connect otherwise, as today: a client with tokens always carries one in - SETUP or the URL, so the relay needs no admission deadline. The URL query - and the SETUP token stay the connect-time credentials in the - `moq_auth::Request`. -- A URL credential is still refused at connect when both arms reject it, and - an in-band refusal is an AUTH_ERROR after connect. -- Docs: `doc/bin/cli.md`, `doc/bin/relay/auth.md` (that the URL token is one - member of the union), `doc/lib/*` client - configuration, and the example invocations carrying `?jwt=` in `doc/`, - `demo/`, and the READMEs, keeping the URL form documented as the way to - reach an older relay. -- Spec the SETUP token parameter in `drafts/draft-lcurley-moq-lite.md`'s - lite-07-wip SETUP, and update `js/net` beside Rust. -- Tests: a client with a configured token offering only lite-07 against an - AUTH-capable relay is scoped exactly as the URL variant, with the token in - SETUP and none in the URL; the same client against a lite-05 relay still - authenticates through the URL; a moq-transport client offering a draft - without AUTH, carrying the token in both the URL and the setup option, is - admitted by `moq auth serve`; two configured tokens union; the - cross-language harness runs with tokens configured. - -Additive. Wire: a lite-07-wip SETUP parameter. diff --git a/quest/m1/lite07-finalize.md b/quest/m1/lite07-finalize.md index ca7a49779f2..b2a5c354c69 100644 --- a/quest/m1/lite07-finalize.md +++ b/quest/m1/lite07-finalize.md @@ -67,10 +67,9 @@ The cut: names that rejection, not only the cap. Decided 2026-10-20 (maintainer): [Drop set](/quest/m1/drop-set.md) joins -Required, because it adds `Max Age` to lite-07's SUBSCRIBE_DROP. Only -[Token in band](/quest/m1/auth/token-in-band.md) gates the cut from the auth -line, because it is that line's lite-07 wire work; the rest is API work that -does not block the rename. +Required, because it adds `Max Age` to lite-07's SUBSCRIBE_DROP. Nothing +else from the auth line gates the cut: its lite-07 wire work, the SETUP Token +parameter, has landed, and the rest is API work that does not block the rename. The archive line's TRACKS request (a per-broadcast track listing that replaced SUBSCRIBE_ALL) landed in [#6158](https://github.com/moq-dev/moq/pull/6158) @@ -96,7 +95,6 @@ timestamp bound in [#6168](https://github.com/moq-dev/moq/pull/6168). ## Required -- [Token in band](/quest/m1/auth/token-in-band.md) - lite-07 carries the Auth Stream and UNAUTHORIZED (0x3B) - [Routes and announces](/quest/m1/cluster-routing/routes.md) - lite-07 carries the route layer: ROUTE per origin node and path-less ANNOUNCE, with the hop list gone - [Drop set](/quest/m1/drop-set.md) - lite-07's SUBSCRIBE_DROP carries `Max Age`, so a widening re-offers groups reported dropped - [Subscribe ranges](/quest/m1/subscribe-ranges/README.md) - SUBSCRIBE carries ranges and an order and lite FETCH is gone, in Rust and JS diff --git a/rs/moq-c/src/api.rs b/rs/moq-c/src/api.rs index c5b2179a5b7..00a621501d7 100644 --- a/rs/moq-c/src/api.rs +++ b/rs/moq-c/src/api.rs @@ -1245,6 +1245,15 @@ pub struct moq_client_config { pub implementation: *const c_char, /// The length in bytes of `implementation`. pub implementation_len: usize, + + /// Credentials every dial presents, or NULL for none. The first is the connection + /// credential, sent in SETUP and, while an offered version can't carry it there (lite + /// before 07), as the URL's `?jwt=`; each other token widens the grant over in-band + /// AUTH. A `?jwt=` already in the URL stays the connection credential, and then every + /// token rides AUTH. An empty token fails the dial. + pub tokens: *const moq_string, + /// The number of entries in `tokens`. + pub tokens_len: usize, } /// The settings [moq_session_connect] dials with when given NULL. diff --git a/rs/moq-c/src/client.rs b/rs/moq-c/src/client.rs index 09a512b3850..0480c81f8bf 100644 --- a/rs/moq-c/src/client.rs +++ b/rs/moq-c/src/client.rs @@ -64,6 +64,14 @@ pub unsafe fn parse_client(config: Option<&moq_client_config>) -> Result>()?; } + out.connect.tokens = unsafe { ffi::parse_strings(config.tokens, config.tokens_len)? } + .into_iter() + .map(|token| { + token + .try_into() + .map_err(|err: moq_tokio::connect::EmptyToken| Error::InvalidConfig(err.to_string())) + }) + .collect::>()?; // Transport if let Some(bind) = unsafe { ffi::parse_str_optional(config.bind, config.bind_len)? } { diff --git a/rs/moq-c/src/test.rs b/rs/moq-c/src/test.rs index 5a0c5e9c29d..7b8ada14d08 100644 --- a/rs/moq-c/src/test.rs +++ b/rs/moq-c/src/test.rs @@ -5205,6 +5205,27 @@ fn implementation_prefixes_the_library() { assert!(unsafe { crate::parse_client(Some(&config)) }.is_err()); } +/// Configured tokens reach the dial in order, and an empty one fails it. +#[test] +fn tokens_reach_the_dial() { + assert!(parsed(&client_config()).connect.tokens.is_empty()); + + let mut config = client_config(); + let tokens = [moq_str("a"), moq_str("b")]; + config.tokens = tokens.as_ptr(); + config.tokens_len = tokens.len(); + let parsed = parsed(&config).connect.tokens; + assert_eq!( + parsed.iter().map(|token| token.as_str()).collect::>(), + ["a", "b"] + ); + + let empty = [moq_str("a"), moq_str("")]; + config.tokens = empty.as_ptr(); + config.tokens_len = empty.len(); + assert!(unsafe { crate::parse_client(Some(&config)) }.is_err()); +} + /// The whole point of the `has_*` flags: a caller who zeroes the struct and sets /// nothing must land on the defaults, not on zero. The backoff and the WebSocket /// fallback are the ones with non-zero defaults, so they are what would break. diff --git a/rs/moq-ffi/MIGRATE.md b/rs/moq-ffi/MIGRATE.md index 4e987c81481..7b159619a87 100644 --- a/rs/moq-ffi/MIGRATE.md +++ b/rs/moq-ffi/MIGRATE.md @@ -5,6 +5,10 @@ These are the UniFFI names; each language wrapper has its own `MIGRATE.md` in it ## Unreleased +### `MoqClientConfig` gains `tokens` + +It defaults to empty in every generated binding; a Rust struct literal adds `tokens: vec![]` or ends with `..Default::default()`. + ### Durations are typed Every duration is a UniFFI `Duration` instead of an integer microsecond field, and drops its `_us` suffix: each language receives its own duration type (`timedelta`, `TimeInterval`, `java.time.Duration`, `time.Duration`, Dart `Duration`, C++ `std::chrono` nanoseconds). diff --git a/rs/moq-ffi/src/session.rs b/rs/moq-ffi/src/session.rs index 1c7f4f4527c..1469ac10307 100644 --- a/rs/moq-ffi/src/session.rs +++ b/rs/moq-ffi/src/session.rs @@ -31,6 +31,11 @@ pub struct MoqClientConfig { /// Empty offers every supported version; [`moq_versions`] lists them. #[uniffi(default = [])] pub versions: Vec, + /// Credentials every dial presents. The first is the connection credential, unless the URL + /// carries its own `?jwt=`; each other one widens the session's grant. Replace them through + /// [`MoqClient::tokens`]. + #[uniffi(default = [])] + pub tokens: Vec, /// How long one connect may take, dial and handshake together. 0 waits forever. #[uniffi(default = None)] pub timeout: Option, @@ -310,6 +315,20 @@ pub(crate) fn parse_versions(versions: &[String]) -> Result) -> Result, MoqError> { + tokens + .into_iter() + .map(|token| { + token + .try_into() + .map_err(|err: moq_tokio::connect::EmptyToken| MoqError::Config { + reason: err.to_string(), + }) + }) + .collect() +} + /// Native QUIC/WebTransport client state: the configured endpoint and the wired origins. #[cfg(not(target_arch = "wasm32"))] struct Client { @@ -332,6 +351,7 @@ impl Client { connect.bind = Some(bind); } connect.version = parse_versions(&config.versions)?; + connect.tokens = parse_tokens(config.tokens)?; connect.timeout = config.timeout.map(Into::into); connect.race = config.race.map(Into::into); connect.resolution_delay = config.resolution_delay.map(Into::into); @@ -574,6 +594,12 @@ mod tests { }; assert!(matches!(MoqClient::new(missing), Err(MoqError::Config { .. }))); + let empty = MoqClientConfig { + tokens: vec![String::new()], + ..Default::default() + }; + assert!(matches!(MoqClient::new(empty), Err(MoqError::Config { .. }))); + for prefix in ["", " app/1", "app/1 ", "app/1 lib/2", "app/1\tlib/2", "app/1\n"] { let implementation = MoqClientConfig { implementation: Some(prefix.into()), @@ -586,12 +612,20 @@ mod tests { } } + #[test] + fn setting_tokens_refuses_an_empty_token() { + let tokens = MoqClient::new(MoqClientConfig::default()).unwrap().tokens(); + tokens.set(vec!["a".into(), "b".into()]).unwrap(); + assert!(matches!(tokens.set(vec![String::new()]), Err(MoqError::Config { .. }))); + } + #[test] fn new_accepts_the_defaults_and_known_versions() { MoqClient::new(MoqClientConfig::default()).unwrap(); MoqClient::new(MoqClientConfig { bind: Some("127.0.0.1:0".into()), versions: vec!["moq-lite-03".into()], + tokens: vec!["credential".into()], once: true, backoff: MoqBackoff { timeout: Some(Duration::ZERO), @@ -625,6 +659,8 @@ impl Client { let MoqClientConfig { bind, versions, + // Presenting tokens needs the native client's dial and AUTH handling. + tokens, timeout, race, resolution_delay, @@ -644,6 +680,7 @@ impl Client { ..tls.clone() }; if bind.is_some() + || !tokens.is_empty() || timeout.is_some() || race.is_some() || resolution_delay.is_some() @@ -745,6 +782,10 @@ fn decode_hex(hex: &str) -> Result, MoqError> { #[derive(uniffi::Object)] pub struct MoqClient { task: Task, + /// Held outside the task, which a connect holds for as long as it dials, so a refresh + /// can land mid-dial. + #[cfg(not(target_arch = "wasm32"))] + tokens: moq_tokio::connect::Tokens, } #[uniffi::export] @@ -754,10 +795,20 @@ impl MoqClient { pub fn new(config: MoqClientConfig) -> Result, MoqError> { let client = Client::new(config)?; Ok(Arc::new(Self { + #[cfg(not(target_arch = "wasm32"))] + tokens: client.client.tokens(), task: Task::new(client), })) } + /// The credentials every dial presents, to replace while running. + pub fn tokens(&self) -> Arc { + Arc::new(MoqTokens { + #[cfg(not(target_arch = "wasm32"))] + inner: self.tokens.clone(), + }) + } + /// Start a connection without waiting for its first dial to succeed. /// /// A native session automatically reconnects with backoff when the transport drops @@ -784,6 +835,35 @@ impl MoqClient { } } +/// The credentials a [`MoqClient`] presents, replaceable while it runs. +#[derive(uniffi::Object)] +pub struct MoqTokens { + #[cfg(not(target_arch = "wasm32"))] + inner: moq_tokio::connect::Tokens, +} + +#[uniffi::export] +impl MoqTokens { + /// Replace the credentials every dial presents, such as before the current ones expire. + /// + /// The next dial presents them, and a live session that supports in-band AUTH presents + /// them at once, withdrawing each replaced token once its replacements are answered, + /// even when one is refused. After an authorization refusal the client redials only if + /// they changed. A browser client has no tokens to replace, so this fails `Unsupported`. + pub fn set(&self, tokens: Vec) -> Result<(), MoqError> { + #[cfg(not(target_arch = "wasm32"))] + { + self.inner.set(parse_tokens(tokens)?); + Ok(()) + } + #[cfg(target_arch = "wasm32")] + { + let _ = tokens; + Err(MoqError::Unsupported) + } + } +} + /// A snapshot of connection statistics for a [`MoqSession`]. /// /// Each field is `None` when the transport backend doesn't report that metric (native QUIC diff --git a/rs/moq-net/src/client.rs b/rs/moq-net/src/client.rs index ed2524b62da..058071d220f 100644 --- a/rs/moq-net/src/client.rs +++ b/rs/moq-net/src/client.rs @@ -17,6 +17,7 @@ pub struct Client { stats: stats::Session, versions: Versions, url: Option, + setup_token: Option, cost: Option, peer_hop: Option, limits: crate::session::Limits, @@ -155,6 +156,19 @@ impl Client { }) } + /// Present `token` as the connection credential in SETUP, on versions that carry one + /// (moq-lite-07+ and every moq-transport draft we speak). The session's empty-token + /// AUTH stream then answers with its grant. + /// + /// Other versions send nothing, so a caller that may negotiate one also puts the + /// token in the URL query. moq-lite carries no Token Type, so only + /// [`Token::OUT_OF_BAND`](setup::Token::OUT_OF_BAND) reaches it; another type fails + /// the handshake. + pub fn with_token(mut self, token: setup::Token) -> Self { + self.setup_token = Some(token); + self + } + /// Build every session on `auth`, so each presents the tokens it holds and a route /// naming one of its [`Auth`](crate::auth::Auth)s keeps it across a reconnect. Omit to /// give each session a handle of its own, which ends with it. @@ -293,6 +307,8 @@ impl Client { cost: self.cost, // Filled by `lite::start` from the attached origin handles. hop: None, + // Older versions carry the credential in the URL query instead. + token: self.setup_token.clone().filter(|_| version.has_auth()), ietf: self.extensions.ietf && version.has_ietf(), implementation: self.implementation(), } @@ -383,6 +399,7 @@ impl Client { version: draft, path: self.setup_path(session.address()), authority: self.setup_authority(session.address()), + token: self.setup_token.clone(), peer_setup_stream: None, peer_declared: None, auth: auth.clone(), @@ -479,6 +496,9 @@ impl Client { if let Some(authority) = self.setup_authority(session.address()) { parameters.set_bytes(ietf::ParameterBytes::Authority, authority.into_bytes()); } + if let Some(token) = &self.setup_token { + ietf::token::into_setup(&mut parameters, token, ietf_encoding)?; + } if self.extensions.solicit { ietf::solicit::into_setup(&mut parameters, ietf_encoding); } @@ -572,6 +592,7 @@ impl Client { version: v, path: None, authority: None, + token: None, peer_setup_stream: None, peer_declared: Some(peer_declared), auth: auth.clone(), @@ -989,7 +1010,11 @@ mod tests { /// The client SETUP on the bidi control stream (the pre-draft-17 framing) carries the /// AUTHORITY next to the PATH. #[moq_clock::test] - async fn draft14_setup_carries_the_authority() { + async fn draft14_setup_carries_the_authority_and_token() { + let token = setup::Token { + kind: setup::Token::OUT_OF_BAND, + value: b"connection-credential".to_vec(), + }; let fake = FakeSession::new(Some(ALPN_LITE), mock_server_setup(Version::Lite(lite::Version::Lite01))); let client = Client::new() .with_versions( @@ -999,7 +1024,8 @@ mod tests { ] .into(), ) - .with_url("moqt://relay.example.com:4443/anon".parse().unwrap()); + .with_url("moqt://relay.example.com:4443/anon".parse().unwrap()) + .with_token(token.clone()); let (_session, driver) = client.connect(moq_clock::now(), fake.clone()).await.unwrap(); moq_clock::sim::spawn(crate::time::run(driver)); @@ -1015,6 +1041,10 @@ mod tests { parameters.get_bytes(ietf::ParameterBytes::Path), Some(b"/anon".as_ref()) ); + assert_eq!( + ietf::token::from_setup(¶meters, ietf::Version::Draft14).unwrap(), + Some(token) + ); } fn dialed(url: &str) -> Client { diff --git a/rs/moq-net/src/ietf/session.rs b/rs/moq-net/src/ietf/session.rs index 104314e4109..b44ebb72582 100644 --- a/rs/moq-net/src/ietf/session.rs +++ b/rs/moq-net/src/ietf/session.rs @@ -83,6 +83,10 @@ pub struct Config { /// The URI authority we advertise in our SETUP, under the same rules as `path`. pub authority: Option, + /// The connection credential our SETUP's `AUTHORIZATION TOKEN` option presents. A + /// server passes `None`. + pub token: Option, + /// The peer's SETUP stream, when it was already read before [`start`] (a draft-17+ /// server that gated on the client's path via [`accept_setup`]). It becomes the /// GOAWAY channel; `None` lets the uni loop read the SETUP itself. @@ -152,6 +156,7 @@ pub fn start(config: Config) -> Result<(Driver, crate::goaway::Handle), Error> { version, path, authority, + token, peer_setup_stream, peer_declared, auth, @@ -417,6 +422,7 @@ pub fn start(config: Config) -> Result<(Driver, crate::goaway::Handle), Error> { version, path, authority, + token, implementation, self_origin, cost, @@ -787,7 +793,8 @@ fn peer_from_params(params: &ietf::Parameters, version: Version) -> Result, authority: Option, + token: Option, implementation: Option, self_origin: Hop, cost: Option, @@ -826,6 +834,9 @@ async fn run_setup( if let Some(authority) = authority { parameters.set_bytes(ietf::ParameterBytes::Authority, authority.into_bytes()); } + if let Some(token) = &token { + super::token::into_setup(&mut parameters, token, version)?; + } cluster::peer_into_setup(&mut parameters, self_origin, cost, version); if extensions.solicit { solicit::into_setup(&mut parameters, version); @@ -1601,6 +1612,7 @@ mod tests { version, path: None, authority: None, + token: None, peer_setup_stream: None, peer_declared: Some(peer::Peer { solicit: Some(true), @@ -1697,6 +1709,7 @@ mod tests { version, path: None, authority: None, + token: None, peer_setup_stream: None, peer_declared: Some(peer::Peer { auth: true, @@ -1755,6 +1768,7 @@ mod tests { version, path: None, authority: None, + token: None, peer_setup_stream: None, peer_declared: Some(peer::Peer { auth: true, @@ -1808,6 +1822,7 @@ mod tests { version, path: None, authority: None, + token: None, peer_setup_stream: None, peer_declared: Some(peer::Peer { auth: true, @@ -1863,6 +1878,7 @@ mod tests { version, path: None, authority: None, + token: None, peer_setup_stream: None, peer_declared: Some(peer::Peer { auth: true, @@ -1911,6 +1927,7 @@ mod tests { version, path: None, authority: None, + token: None, peer_setup_stream: None, peer_declared: Some(peer::Peer { auth: true, @@ -2017,6 +2034,7 @@ mod tests { version: VERSION, path: None, authority: None, + token: None, peer_setup_stream: None, // A peer that declared its Hop ID negotiated the extension, which is what // makes the cluster parameters mandatory in both directions. @@ -2084,6 +2102,7 @@ mod tests { version: Version::Draft18, path: None, authority: None, + token: None, peer_setup_stream: None, // The requests wait on the peer's SETUP (MoQ Hidden). peer_declared: Some(peer::Peer::default()), @@ -2200,6 +2219,7 @@ mod tests { version, path: None, authority: None, + token: None, peer_setup_stream: None, peer_declared: Some(peer::Peer { solicit, @@ -2461,6 +2481,7 @@ mod tests { version: Version::Draft18, path: None, authority: None, + token: None, peer_setup_stream: None, peer_declared, auth: crate::auth::Link::new(false), @@ -2567,6 +2588,7 @@ mod tests { version: Version::Draft18, path: None, authority: None, + token: None, peer_setup_stream: None, peer_declared: Some(peer::Peer { auth, @@ -2655,6 +2677,7 @@ mod tests { version: VERSION, path: None, authority: None, + token: None, peer_setup_stream: None, peer_declared: Some(peer::Peer::default()), auth, @@ -2754,6 +2777,7 @@ mod tests { version: VERSION, path: None, authority: None, + token: None, peer_setup_stream: None, peer_declared: Some(peer::Peer { auth: true, @@ -2846,6 +2870,7 @@ mod tests { version: VERSION, path: None, authority: None, + token: None, peer_setup_stream: None, // Pre-settled, so nothing waits on a SETUP the dead stream will never // carry and the dispatch loop actually runs. @@ -2865,10 +2890,12 @@ mod tests { assert_eq!(log.closes(), vec![], "nothing may close the transport"); } - /// A draft-17+ client advertises its AUTHORITY in the SETUP it writes on its uni stream. + /// A draft-17+ client advertises its AUTHORITY and connection credential in the SETUP + /// it writes on its uni stream. #[moq_clock::test] - async fn setup_carries_the_authority() { + async fn setup_carries_the_authority_and_token() { const AUTHORITY: &[u8] = b"relay.example.com:4443"; + const TOKEN: &[u8] = b"connection-credential"; // The driver parks forever once SETUP is out, so bound it: paused time makes the // deadline fire the moment nothing else can run. @@ -2892,6 +2919,10 @@ mod tests { version, path: None, authority: Some(String::from_utf8(AUTHORITY.to_vec()).unwrap()), + token: Some(crate::setup::Token { + kind: crate::setup::Token::OUT_OF_BAND, + value: TOKEN.to_vec(), + }), peer_setup_stream: None, peer_declared: Some(peer::Peer::default()), auth: crate::auth::Link::new(false), @@ -2911,6 +2942,11 @@ mod tests { 1, "{version:?}: SETUP must carry the authority once" ); + assert_eq!( + occurrences(&log, TOKEN), + 1, + "{version:?}: SETUP must carry the token once" + ); } #[moq_clock::test] @@ -3163,6 +3199,7 @@ mod tests { version: Version::Draft18, path: None, authority: None, + token: None, peer_setup_stream: None, peer_declared: Some(peer::Peer::default()), auth: crate::auth::Link::new(false), @@ -3298,6 +3335,7 @@ mod tests { version, path: None, authority: None, + token: None, peer_setup_stream: None, peer_declared: Some(peer::Peer::default()), auth: crate::auth::Link::new(false), @@ -3368,6 +3406,7 @@ mod tests { version, path: None, authority: None, + token: None, peer_setup_stream: None, peer_declared: Some(peer::Peer::default()), auth: crate::auth::Link::new(false), @@ -3478,6 +3517,7 @@ mod tests { version: VERSION, path: None, authority: None, + token: None, peer_setup_stream: None, peer_declared: None, auth: crate::auth::Link::new(false), @@ -3608,6 +3648,7 @@ mod tests { version, path: None, authority: None, + token: None, peer_setup_stream: None, peer_declared: Some(peer::Peer::default()), auth: crate::auth::Link::new(false), @@ -3852,6 +3893,7 @@ mod tests { version: VERSION, path: None, authority: None, + token: None, peer_setup_stream: None, peer_declared: None, auth: crate::auth::Link::new(false), @@ -3917,6 +3959,7 @@ mod tests { None, None, None, + None, Hop::new(1).unwrap(), None, Default::default(), @@ -3958,6 +4001,7 @@ mod tests { version, path: None, authority: None, + token: None, peer_setup_stream: None, peer_declared: Some(peer::Peer::default()), auth: crate::auth::Link::new(false), diff --git a/rs/moq-net/src/ietf/token.rs b/rs/moq-net/src/ietf/token.rs index 5e178398531..2c852b38d4c 100644 --- a/rs/moq-net/src/ietf/token.rs +++ b/rs/moq-net/src/ietf/token.rs @@ -39,7 +39,6 @@ pub fn from_setup(params: &Parameters, version: Version) -> Result } /// Present `token` in our SETUP, by value. -#[cfg_attr(not(test), expect(dead_code, reason = "only tests use it"))] pub fn into_setup(params: &mut Parameters, token: &Token, version: Version) -> Result<(), EncodeError> { params.set_bytes(ParameterBytes::AuthorizationToken, encode(token, version)?); Ok(()) diff --git a/rs/moq-net/src/lite/setup.rs b/rs/moq-net/src/lite/setup.rs index 79dd6a7a4ad..1f7fc68063d 100644 --- a/rs/moq-net/src/lite/setup.rs +++ b/rs/moq-net/src/lite/setup.rs @@ -17,6 +17,8 @@ const PARAM_COST: u64 = 0x4; const PARAM_HOP: u64 = 0x5; /// Setup Parameter id for the sender's implementation, as `name/version` product tokens. const PARAM_IMPLEMENTATION: u64 = 0x6; +/// Setup Parameter id for the client's connection credential (client-only, lite-07+). +const PARAM_TOKEN: u64 = 0x7; /// Setup Parameter id offering the `ietf` extension (draft-lcurley-moq-ietf): "IETF" in ASCII. const PARAM_IETF: u64 = 0x4945_5446; @@ -188,6 +190,10 @@ pub struct Setup { /// applies). `None` when the endpoint has no meaningful identity (a /// leaf that never forwards); a wire value of 0 decodes as `None`. pub hop: Option, + /// The connection credential (lite-07+), which the empty-token Auth Stream names. + /// Sent only by the client. The parameter carries no Token Type, so it is always + /// [`Token::OUT_OF_BAND`](crate::setup::Token::OUT_OF_BAND). + pub token: Option, /// Whether this endpoint offers the `ietf` extension (lite-07+). Negotiated only when /// both endpoints offer it. pub ietf: bool, @@ -231,6 +237,16 @@ impl Message for Setup { if let Some(implementation) = &self.implementation { params.set_bytes(PARAM_IMPLEMENTATION, implementation.as_bytes().to_vec()); } + if let Some(token) = &self.token { + if !version.has_auth() { + return Err(EncodeError::Version); + } + // An empty value is no credential, and the untyped parameter cannot carry another type. + if token.kind != crate::setup::Token::OUT_OF_BAND || token.value.is_empty() { + return Err(EncodeError::Unsupported); + } + params.set_bytes(PARAM_TOKEN, token.value.clone()); + } if self.ietf { // The framing it changes is unpublished, so a published version cannot offer it. if !version.has_ietf() { @@ -281,7 +297,15 @@ impl Message for ReceivedSetup { .get_varint(PARAM_HOP, version)? .and_then(|id| crate::Hop::new(id).ok()); let implementation = crate::setup::decode_implementation(params.get_bytes(PARAM_IMPLEMENTATION))?; - // An older version does not know the parameter, so it ignores it like any other. + // An older version does not know these parameters, so it ignores them like any other. + let token = match params.get_bytes(PARAM_TOKEN) { + Some([]) if version.has_auth() => return Err(DecodeError::InvalidValue), + Some(value) if version.has_auth() => Some(crate::setup::Token { + kind: crate::setup::Token::OUT_OF_BAND, + value: value.to_vec(), + }), + _ => None, + }; let ietf = match params.get_bytes(PARAM_IETF) { Some(value) if version.has_ietf() => match value.is_empty() { true => true, @@ -298,6 +322,7 @@ impl Message for ReceivedSetup { role, cost, hop, + token, ietf, implementation, }, @@ -349,7 +374,7 @@ impl PeerSetup { pub(super) fn receive(&self, received: ReceivedSetup) -> Result<(), crate::Error> { let mut state = self.0.lock(); if ((state.client || state.address == crate::transport::Address::Uri) && received.setup.path.is_some()) - || (state.client && received.role_present) + || (state.client && (received.role_present || received.setup.token.is_some())) { return Err(crate::Error::ProtocolViolation); } @@ -580,6 +605,72 @@ mod tests { assert!(Setup::decode_slice(&valued, Version::Lite07).is_err()); } + /// The connection credential rides lite-07+, which an older version ignores like any + /// unknown parameter, and only an untyped, non-empty token can be sent. + #[test] + fn token_is_lite07_only() { + let token = crate::setup::Token { + kind: crate::setup::Token::OUT_OF_BAND, + value: b"jwt".to_vec(), + }; + let msg = Setup { + token: Some(token.clone()), + ..Default::default() + }; + let buf = msg.encode_bytes(Version::Lite07).unwrap(); + // Length, one parameter, its id, then the token's length and bytes. + assert_eq!(&buf[..], [0x06, 0x01, 0x07, 0x03, b'j', b'w', b't']); + assert_eq!(Setup::decode_slice(&buf, Version::Lite07).unwrap().0, msg); + assert!(matches!(msg.encode_bytes(Version::Lite06), Err(EncodeError::Version))); + assert_eq!(Setup::decode_slice(&buf, Version::Lite06).unwrap().0, Setup::default()); + + for unsendable in [ + crate::setup::Token { + // Any registered type but out-of-band, which moq-lite cannot carry. + kind: 0x1, + ..token.clone() + }, + crate::setup::Token { + value: Vec::new(), + ..token + }, + ] { + let msg = Setup { + token: Some(unsendable), + ..Default::default() + }; + assert!(matches!( + msg.encode_bytes(Version::Lite07), + Err(EncodeError::Unsupported) + )); + } + + // An empty value carries no credential, so it is refused rather than read as none. + assert!(Setup::decode_slice(&[0x03, 0x01, 0x07, 0x00], Version::Lite07).is_err()); + } + + /// Only the client presents a connection credential. + #[test] + fn a_server_token_is_a_protocol_violation() { + let setup = Setup { + token: Some(crate::setup::Token { + kind: crate::setup::Token::OUT_OF_BAND, + value: b"jwt".to_vec(), + }), + ..Default::default() + }; + let buf = setup.encode_bytes(Version::Lite07).unwrap(); + let received = |buf: &[u8]| ReceivedSetup::decode_slice(buf, Version::Lite07).unwrap().0; + + let client = PeerSetup::new(true, crate::transport::Address::Setup); + assert!(matches!( + client.receive(received(&buf)), + Err(crate::Error::ProtocolViolation) + )); + let server = PeerSetup::new(false, crate::transport::Address::Setup); + server.receive(received(&buf)).unwrap(); + } + /// Never emit a SETUP our own receiver would refuse. #[test] fn encode_enforces_the_setup_limit() { diff --git a/rs/moq-net/src/server.rs b/rs/moq-net/src/server.rs index a13de9735dc..23982d2e9fb 100644 --- a/rs/moq-net/src/server.rs +++ b/rs/moq-net/src/server.rs @@ -176,6 +176,7 @@ impl Server { cost: None, // Filled by `lite::start` from the attached origin handles. hop: None, + token: None, ietf: self.extensions.ietf && version.has_ietf(), implementation: self.implementation(), } @@ -281,12 +282,15 @@ impl Server { } PausedHandshake::IetfModern(_) | PausedHandshake::Legacy(_) => false, }); + let token = match &handshake { + PausedHandshake::LiteSetup { client_setup, .. } => client_setup.setup.token.clone(), + _ => None, + }; Ok(Handshake { path, role, origin, - // moq-lite carries no SETUP token. - token: None, + token, peer_implementation, assigned_hop: crate::Hop::random(), auth, @@ -595,6 +599,7 @@ impl PausedIetfModern { version, path: None, authority: None, + token: None, peer_setup_stream: Some(peer_setup.stream), peer_declared: Some(peer_setup.declared), auth: auth.clone(), @@ -737,6 +742,7 @@ impl PausedLegacy { version: v, path: None, authority: None, + token: None, peer_setup_stream: None, peer_declared: Some(peer_declared), auth: auth.clone(), @@ -842,10 +848,11 @@ impl Handshake { self.auth.handle().requests() } - /// The credential the client presented in its SETUP's `AUTHORIZATION TOKEN` option. + /// The connection credential the client presented in its SETUP: moq-transport's + /// `AUTHORIZATION TOKEN` option, or moq-lite-07's Token parameter. /// - /// Only moq-transport carries one, so moq-lite sessions return `None`. The transport - /// has not verified it: authorize on it the way you would a URL token. + /// Older moq-lite versions carry none, so they return `None`. The transport has not + /// verified it: authorize on it the way you would a URL token. pub fn token(&self) -> Option<&setup::Token> { self.token.as_ref() } @@ -1258,6 +1265,7 @@ mod tests { role, cost: None, hop, + token: None, ietf: false, implementation: None, } diff --git a/rs/moq-net/src/setup.rs b/rs/moq-net/src/setup.rs index 6a1a4945143..bfb8ba99c6d 100644 --- a/rs/moq-net/src/setup.rs +++ b/rs/moq-net/src/setup.rs @@ -104,10 +104,11 @@ impl Default for Extensions { } } -/// A credential a moq-transport peer presented in its SETUP's `AUTHORIZATION TOKEN` option. +/// A connection credential presented in SETUP: moq-transport's `AUTHORIZATION TOKEN` +/// option, or moq-lite-07's Token parameter. /// /// The transport never reads the bytes; verifying them is the application's job. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Clone, PartialEq, Eq)] pub struct Token { /// The wire Token Type, naming how [`value`](Self::value) is encoded. pub kind: u64, @@ -120,6 +121,13 @@ impl Token { pub const OUT_OF_BAND: u64 = 0x0; } +// SETUP and handshakes are logged with `Debug`, so the credential shows only its type and length. +impl std::fmt::Debug for Token { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Token(kind={}, <{} bytes>)", self.kind, self.value.len()) + } +} + /// Draft-17+ unified SETUP message, with the same encoding for both client and server. #[derive(Debug, Clone)] pub(crate) struct Setup { diff --git a/rs/moq-net/src/version.rs b/rs/moq-net/src/version.rs index 4f80533c721..28188688fe4 100644 --- a/rs/moq-net/src/version.rs +++ b/rs/moq-net/src/version.rs @@ -84,6 +84,16 @@ impl Version { } } + /// Whether the client's SETUP can carry its connection credential (see + /// `Client::with_token`, behind the `async` feature). Every moq-transport draft can; + /// moq-lite gained it in lite-07, so older versions need the credential in the URL. + pub fn has_setup_token(self) -> bool { + match self { + Self::Lite(version) => version.has_auth(), + Self::Ietf(_) => true, + } + } + /// Handshake / config name, e.g. `moq-lite-05` or `moq-transport-22`. pub fn as_str(&self) -> &'static str { match self { diff --git a/rs/moq-net/tests/integration/auth.rs b/rs/moq-net/tests/integration/auth.rs index 5d3db13f0fe..415f61d7088 100644 --- a/rs/moq-net/tests/integration/auth.rs +++ b/rs/moq-net/tests/integration/auth.rs @@ -62,6 +62,7 @@ cases!( a_revoked_grant_withdraws_and_can_be_restored, a_refused_token_reports_the_code, a_refused_setup_token_grants_nothing, + the_setup_token_is_the_connection_credential, dropping_the_requests_refuses_queued_tokens, a_closed_session_holds_no_grant, an_issued_grant_ends_with_its_session, @@ -458,6 +459,8 @@ struct Options { client_extensions: Option, server_extensions: Option, version: Option<&'static str>, + /// The connection credential the client's SETUP presents. + client_token: Option<&'static [u8]>, /// The handle the client builds its session on, when not its own. client_auth: Option, } @@ -468,6 +471,8 @@ struct Pair { client_transport: MockSession, server_transport: MockSession, requests: Option, + /// The SETUP token the server's handshake read. + setup_token: Option, /// Aborting it drops the server's driver without letting it finish. server_driver: moq_clock::sim::JoinHandle, } @@ -479,6 +484,12 @@ async fn connect(opts: Options) -> Pair { let (client_transport, server_transport) = create_mock_session_pair(mock); let mut client = Client::new().with_versions(version.into()); + if let Some(token) = opts.client_token { + client = client.with_token(moq_net::setup::Token { + kind: moq_net::setup::Token::OUT_OF_BAND, + value: token.to_vec(), + }); + } if let Some(extensions) = opts.client_extensions { client = client.with_extensions(extensions); } @@ -515,15 +526,16 @@ async fn connect(opts: Options) -> Pair { .accept_request(now(), server_transport) .await .expect("server handshake"); + let setup_token = handshake.token().cloned(); // Taken before the session starts, the way a relay verifying tokens would. let requests = opts.server_requests.then(|| handshake.requests()); let (session, mut driver) = handshake.ok().await.expect("server accept"); let driver = moq_clock::sim::spawn(moq_clock::drive(move |now, waiter| { moq_net::time::Driver::poll(&mut driver, now, waiter) })); - (session, requests, driver) + (session, requests, setup_token, driver) }; - let (client, (server, requests, server_driver)) = futures::join!(client_fut, server_fut); + let (client, (server, requests, setup_token, server_driver)) = futures::join!(client_fut, server_fut); Pair { client, @@ -531,6 +543,7 @@ async fn connect(opts: Options) -> Pair { client_transport: observe, server_transport: observe_server, requests, + setup_token, server_driver, } } @@ -921,6 +934,49 @@ async fn a_refused_setup_token_grants_nothing(version: &'static str) { .expect("timed out"); } +/// The token a client's SETUP presents reaches the server's handshake, and the client's +/// empty-token AUTH stream learns the grant the server gives it. +async fn the_setup_token_is_the_connection_credential(version: &'static str) { + within(async { + let mut pair = connect(Options { + version: Some(version), + client_publish: Some(produce_origin(2)), + server_requests: true, + client_token: Some(b"connection-credential"), + ..Default::default() + }) + .await; + assert_eq!( + pair.setup_token.map(|token| (token.kind, token.value)), + Some((moq_net::setup::Token::OUT_OF_BAND, b"connection-credential".to_vec())) + ); + + let _issued = serve(pair.requests.take().unwrap(), |token| { + token.is_empty().then(|| grant(&["room"], &[])) + }); + assert_eq!(granted(&pair.client).await, grant(&["room"], &[])); + }) + .await + .expect("timed out"); +} + +/// A lite version without the Token parameter carries none, so the caller's URL query +/// has to. +#[moq_clock::test] +async fn lite_06_sends_no_setup_token() { + within(async { + let pair = connect(Options { + version: Some(LITE_06), + client_token: Some(b"connection-credential"), + ..Default::default() + }) + .await; + assert_eq!(pair.setup_token, None); + }) + .await + .expect("timed out"); +} + /// Dropping the requests refuses tokens already queued, not only later ones. async fn dropping_the_requests_refuses_queued_tokens(version: &'static str) { within(async { diff --git a/rs/moq-relay/tests/integration/auth_lifetime.rs b/rs/moq-relay/tests/integration/auth_lifetime.rs index 77b49f53efd..3637412c885 100644 --- a/rs/moq-relay/tests/integration/auth_lifetime.rs +++ b/rs/moq-relay/tests/integration/auth_lifetime.rs @@ -179,6 +179,12 @@ async fn spawn_relay_with( let mut config = moq_tokio::listen::Config::default(); config.tcp.bind = Some("127.0.0.1:0".parse().expect("parse addr")); + // Opt in to lite-07 beside the defaults, so a client pinned to it can reach the AUTH stream. + config.version = moq_net::Versions::all() + .iter() + .copied() + .chain(["moq-lite-07-wip".parse().unwrap()]) + .collect(); let server = config.init(Default::default()).expect("server init"); let mut server = server.listen().await.expect("listen"); let port = server.tcp_local_addr().expect("TCP listener is configured").port(); @@ -487,6 +493,74 @@ async fn forwards_the_setup_token() { } } +/// A configured token is the connection credential: on lite-07 it reaches the auth server +/// as the SETUP token with nothing in the query, and on lite-06, which has no SETUP token, +/// in the query as a `?jwt=`. +#[tokio::test] +async fn a_configured_token_is_the_connection_credential() { + let token = moq_auth::Token { + kind: moq_auth::Token::OUT_OF_BAND, + value: b"credential".to_vec(), + }; + for (versions, setup, query) in [ + (vec!["moq-lite-07-wip"], Some(token.clone()), None), + (vec!["moq-lite-06"], None, Some("jwt=credential")), + ] { + let script = Script::new(grant(Duration::from_secs(3600))); + // Over SETUP, anonymous admission fails, so an established session proves the token + // was used. The scripted server cannot check a query token, so that case only asserts + // what it was sent. + if let Some(token) = &setup { + script.on_connect(Answer::Token(grant(Duration::from_secs(3600)), token.clone())); + } + let (port, relay) = spawn_relay(build_auth(script.spawn().await)).await; + + let mut config = moq_tokio::connect::Config::default(); + config.version = versions.iter().map(|version| version.parse().unwrap()).collect(); + config.tokens = vec!["credential".parse().unwrap()]; + let client = config.init(Default::default()).unwrap(); + let url: url::Url = format!("tcp://127.0.0.1:{port}/room").parse().unwrap(); + let session = tokio::time::timeout( + TIMEOUT, + client + .with_reconnect(false) + .with_subscriber(moq_tokio::origin::spawn()) + .connect(url) + .established(), + ) + .await + .expect("connect timeout") + .expect("the configured token was refused"); + if setup.is_some() { + // Lite-07 answers the SETUP token over AUTH, so its grant proves admission. + let mut grant = session.auth().grant(); + tokio::time::timeout(TIMEOUT, async { + while grant.peek().is_none() { + grant.changed().await.expect("the relay refused the configured token"); + } + }) + .await + .expect("grant timeout"); + } else { + // Lite-06 has no AUTH answer and a client is live before admission, so give a + // refusal time to land. + assert!( + tokio::time::timeout(Duration::from_millis(500), session.closed()) + .await + .is_err(), + "{versions:?}: the relay refused the configured token" + ); + } + + let seen = script.seen.lock().unwrap().clone(); + let connect = seen.iter().find(|r| r.event == Event::Connect).expect("a connect"); + assert_eq!(connect.token, setup, "{versions:?}"); + assert_eq!(connect.query.as_deref(), query, "{versions:?}"); + session.abort(moq_net::Error::Cancel); + relay.abort(); + } +} + /// A refusal at connect, a 5xx, and a garbage reply all refuse the session. #[tokio::test] async fn refusals_and_outages_refuse_at_connect() { diff --git a/rs/moq-tokio/src/client.rs b/rs/moq-tokio/src/client.rs index 410fa067362..d87c8a85bc3 100644 --- a/rs/moq-tokio/src/client.rs +++ b/rs/moq-tokio/src/client.rs @@ -50,6 +50,8 @@ pub struct Client { versions: moq_net::Versions, /// The URL from [`connect.url`](crate::connect::Config::url), dialed by [`Client::publish`] / [`Client::consume`]. connect: Option, + /// The credentials every dial presents, shared with each clone. + tokens: crate::connect::Tokens, /// Deadline for one [`Client::connect`], from [`crate::connect::Config::timeout`]. Zero waits forever. #[cfg(feature = "_transport")] timeout: std::time::Duration, @@ -156,6 +158,7 @@ impl Client { moq, versions, connect: config.url, + tokens: crate::connect::Tokens::new(config.tokens), timeout, reconnect: true, backoff: config.backoff, @@ -255,6 +258,13 @@ impl Client { self } + /// The credentials every dial presents, seeded from + /// [`connect.tokens`](crate::connect::Config::tokens). Set new ones before the old + /// ones expire. + pub fn tokens(&self) -> crate::connect::Tokens { + self.tokens.clone() + } + /// Override whether this client redials after a session drop. /// /// Defaults to true. Each clone can choose its own reconnect policy. @@ -360,6 +370,11 @@ impl Client { Ok(session) } + /// What a dial to `url` presents of `tokens`, given the versions this client offers. + pub(crate) fn credential(&self, url: &Url, tokens: &crate::connect::Set) -> crate::connect::Credential { + crate::connect::Credential::new(url, &self.versions, tokens) + } + /// Dial the given URL and complete the MoQ handshake. /// /// Errors if no transport feature is compiled in. @@ -370,13 +385,17 @@ impl Client { feature = "tcp", feature = "uds" )))] - pub(crate) async fn dial(&self, _addr: crate::connect::Addr) -> crate::Result { + pub(crate) async fn dial( + &self, + _addr: crate::connect::Addr, + _credential: &crate::connect::Credential, + ) -> crate::Result { Err(Error::NoBackend( "no backend compiled; enable quic, iroh, websocket, tcp, or uds feature", )) } - /// Dial the given URL and complete the MoQ handshake. + /// Dial the given URL and complete the MoQ handshake, presenting `credential`. /// /// The scheme picks the transport, and `https://` races QUIC against the /// WebSocket fallback so a blocked UDP path still connects. The session's @@ -389,15 +408,31 @@ impl Client { feature = "tcp", feature = "uds" ))] - pub(crate) async fn dial(&self, addr: crate::connect::Addr) -> crate::Result { + pub(crate) async fn dial( + &self, + addr: crate::connect::Addr, + credential: &crate::connect::Credential, + ) -> crate::Result { + let addr = addr.with_url(credential.url.clone()); #[cfg(test)] if let Some(dialer) = &self.dialer { return dialer(addr).await; } + let client = match &credential.setup { + Some(token) => { + let mut client = self.clone(); + client.moq = client.moq.with_token(moq_net::setup::Token { + kind: moq_net::setup::Token::OUT_OF_BAND, + value: token.as_str().as_bytes().to_vec(), + }); + std::borrow::Cow::Owned(client) + } + None => std::borrow::Cow::Borrowed(self), + }; // Each compiled backend adds state to this dispatch future. Keep it off the // caller's stack so all-feature builds remain safe on standard 2 MiB threads. - let attempt = Box::pin(self.connect_inner(addr)); + let attempt = Box::pin(client.connect_inner(addr)); // The deadline covers the dial AND the handshake, for every transport: it is the // only bound some of them have. Dropping `attempt` on expiry cancels whichever @@ -892,6 +927,18 @@ mod tests { assert!(err.to_string().contains("--connect / MOQ_CONNECT"), "{err}"); } + /// Tokens repeat, keep their order, and are refused when empty. + #[test] + fn test_connect_tokens() { + let config = Cli::config_from(["test", "--connect-token", "first", "--connect-token", "second"]); + let tokens: Vec<&str> = config.tokens.iter().map(crate::connect::Token::as_str).collect(); + assert_eq!(tokens, ["first", "second"]); + + let config: crate::connect::Config = toml::from_str(r#"tokens = ["first"]"#).unwrap(); + assert_eq!(config.tokens.len(), 1); + assert!(toml::from_str::(r#"tokens = [""]"#).is_err()); + } + #[test] fn test_cli_resolution_delay() { let config = Cli::config_from(["test", "--connect-resolution-delay", "0s"]); @@ -1371,7 +1418,8 @@ mod tests { // `dial` rather than `connect`: this is about one attempt's deadline, and // `connect` now hands back a reconnect loop that would redial past it. - let mut attempt = Box::pin(client.dial(url.into())); + let credential = client.credential(&url, &Default::default()); + let mut attempt = Box::pin(client.dial(url.into(), &credential)); let _silent = tokio::select! { res = &mut attempt => match res { Err(err) => panic!("connect failed before the silent peer accepted it: {err}"), diff --git a/rs/moq-tokio/src/connect.rs b/rs/moq-tokio/src/connect.rs index c57254249bf..982f04b08b2 100644 --- a/rs/moq-tokio/src/connect.rs +++ b/rs/moq-tokio/src/connect.rs @@ -8,6 +8,10 @@ use crate::connection::Goaway; use std::net; use url::Url; +mod token; +pub(crate) use token::{Credential, Set}; +pub use token::{EmptyToken, Token, Tokens}; + /// Maximum time for one dial, unless overridden by `--connect-timeout`. pub(crate) const DEFAULT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); @@ -85,6 +89,14 @@ impl Addr { pub fn addresses(&self) -> Option<&[net::SocketAddr]> { self.resolved.as_deref() } + + /// The same peer and addresses, requested at `url`. + pub(crate) fn with_url(&self, url: Url) -> Self { + Self { + url, + resolved: self.resolved.clone(), + } + } } impl From for Addr { @@ -498,6 +510,22 @@ pub struct Config { #[usage(name = "connect", long = "connect", env = "MOQ_CONNECT", setting = "connect.url")] pub url: Option, + /// Credentials to present on every dial, kept apart from the URL. + /// + /// The first is the connection credential: it rides SETUP, and the URL query too + /// while an offered version can't carry it in SETUP (lite before 07). Each other + /// token rides its own AUTH stream, widening the session's grant. A URL that + /// already has a `?jwt=` keeps it as the connection credential, and then every + /// token rides AUTH. Replace them while running through `Client::tokens`. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + #[usage( + name = "connect-token", + long = "connect-token", + env = "MOQ_CONNECT_TOKEN", + setting = "connect.tokens" + )] + pub tokens: Vec, + /// The released `connect` key, kept so [`deprecated`](Self::deprecated) can name [`url`](Self::url). #[serde(default, skip_serializing)] #[usage(skip)] diff --git a/rs/moq-tokio/src/connect/token.rs b/rs/moq-tokio/src/connect/token.rs new file mode 100644 index 00000000000..6fc9aff157c --- /dev/null +++ b/rs/moq-tokio/src/connect/token.rs @@ -0,0 +1,309 @@ +//! The credentials a client presents, kept out of logs and replaceable while it runs. + +use moq_net::kio; +use url::Url; + +/// A credential to present when dialing, such as a JWT. +/// +/// Its `Debug` shows only the length, so a dumped config never leaks it. +#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(try_from = "String", into = "String")] +pub struct Token(String); + +impl Token { + /// The credential itself. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Debug for Token { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Token(<{} bytes>)", self.0.len()) + } +} + +impl std::str::FromStr for Token { + type Err = EmptyToken; + + fn from_str(token: &str) -> Result { + Self::try_from(token.to_string()) + } +} + +impl TryFrom for Token { + type Error = EmptyToken; + + fn try_from(token: String) -> Result { + match token.is_empty() { + true => Err(EmptyToken), + false => Ok(Self(token)), + } + } +} + +impl From for String { + fn from(token: Token) -> Self { + token.0 + } +} + +/// An empty token, which presents no credential. +#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)] +#[error("a token cannot be empty")] +#[non_exhaustive] +pub struct EmptyToken; + +/// The tokens a client presents, which every dial reads afresh. +/// +/// Clones share one set, as do the [`crate::Client`] clones it came from. Replacing it +/// reaches the next dial and, on a live session with AUTH, presents the new tokens at +/// once, withdrawing the ones they replace only after every new one has its answer. +#[derive(Clone)] +pub struct Tokens(kio::Producer); + +/// One version of the tokens, numbered so a dial can tell whether they changed since. +#[derive(Clone, Default)] +pub(crate) struct Set { + pub tokens: Vec, + pub generation: u64, +} + +impl Tokens { + pub(crate) fn new(tokens: Vec) -> Self { + Self(kio::Producer::new(Set { tokens, generation: 0 })) + } + + /// Replace every token. The first is the connection credential, unless the dial URL + /// carries its own `?jwt=`. + pub fn set(&self, tokens: Vec) { + // Never closed: this handle is a producer. + let Ok(mut set) = self.0.write() else { return }; + if set.tokens != tokens { + set.tokens = tokens; + set.generation += 1; + } + } + + pub(crate) fn snapshot(&self) -> Set { + self.0.read().clone() + } + + pub(crate) fn consume(&self) -> kio::Consumer { + self.0.consume() + } +} + +/// What one dial presents, decided from its URL, the offered versions, and the tokens. +#[derive(Clone)] +pub(crate) struct Credential { + /// The URL to dial, carrying the connection credential in its query when an offered + /// version has no SETUP token. + pub url: Url, + /// The token SETUP carries, unless the URL brought its own credential. + pub setup: Option, + /// The credential the connection presents, from the URL or SETUP. Never also + /// presented on an AUTH stream. + connection: Option, +} + +impl Credential { + pub fn new(url: &Url, versions: &moq_net::Versions, set: &Set) -> Self { + if let Some(jwt) = jwt(url) { + // The URL's own credential (a cluster's per-peer `?jwt=`) wins, so the tokens + // all ride AUTH and no second credential reaches SETUP or the query. + return Self { + url: url.clone(), + setup: None, + connection: Some(jwt), + }; + } + let Some(first) = set.tokens.first() else { + return Self { + url: url.clone(), + setup: None, + connection: None, + }; + }; + // The URL is fixed before the version is negotiated, so it carries the credential + // whenever a version that may be picked could not carry it in SETUP. + let url = match versions.iter().all(|version| version.has_setup_token()) { + true => url.clone(), + false => with_jwt(url, first.as_str()), + }; + Self { + url, + setup: Some(first.clone()), + connection: Some(first.as_str().to_string()), + } + } + + /// The tokens to hold on their own AUTH streams, each once. + pub fn extra(&self, tokens: &[Token]) -> Vec { + let mut extra: Vec = Vec::new(); + for token in tokens { + if self.connection.as_deref() != Some(token.as_str()) && !extra.contains(token) { + extra.push(token.clone()); + } + } + extra + } +} + +/// The request target's query: the URL's own, or for `unix:` the one inside `?path=`. +fn target_query(url: &Url) -> Option { + match url.scheme() { + "unix" => url + .query_pairs() + .find(|(k, _)| k == "path") + .and_then(|(_, path)| path.split_once('?').map(|(_, query)| query.to_string())), + _ => url.query().map(str::to_string), + } +} + +/// The non-empty `jwt` the URL's request target already carries. +fn jwt(url: &Url) -> Option { + let query = target_query(url)?; + url::form_urlencoded::parse(query.as_bytes()) + .find(|(k, v)| k == "jwt" && !v.is_empty()) + .map(|(_, v)| v.into_owned()) +} + +/// `url` with `jwt` appended to its request target's query. +fn with_jwt(url: &Url, jwt: &str) -> Url { + let mut url = url.clone(); + if url.scheme() != "unix" { + url.query_pairs_mut().append_pair("jwt", jwt); + return url; + } + // A unix socket's request target is one form-encoded `?path=` value, so the + // credential joins the query inside it. + let pairs: Vec<(String, String)> = url.query_pairs().into_owned().collect(); + let mut serializer = url::form_urlencoded::Serializer::new(String::new()); + let mut found = false; + for (k, v) in &pairs { + match k == "path" { + true => { + found = true; + let mut target = url::form_urlencoded::Serializer::new(String::new()); + target.append_pair("jwt", jwt); + let separator = if v.contains('?') { '&' } else { '?' }; + serializer.append_pair(k, &format!("{v}{separator}{}", target.finish())); + } + false => { + serializer.append_pair(k, v); + } + } + } + if !found { + let mut target = url::form_urlencoded::Serializer::new(String::new()); + target.append_pair("jwt", jwt); + serializer.append_pair("path", &format!("?{}", target.finish())); + } + url.set_query(Some(&serializer.finish())); + url +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tokens(tokens: &[&str]) -> Set { + Set { + tokens: tokens.iter().map(|token| token.parse().unwrap()).collect(), + generation: 3, + } + } + + fn versions(names: &[&str]) -> moq_net::Versions { + names + .iter() + .map(|name| name.parse::().unwrap()) + .collect::>() + .into() + } + + /// Only lite-07 and moq-transport carry the credential in SETUP, so the URL stays clean. + #[test] + fn setup_versions_keep_the_credential_out_of_the_url() { + let url: Url = "https://relay.example.com/room".parse().unwrap(); + let credential = Credential::new( + &url, + &versions(&["moq-lite-07-wip", "moq-transport-17"]), + &tokens(&["a", "b"]), + ); + assert_eq!(credential.url, url); + assert_eq!(credential.setup.as_ref().map(Token::as_str), Some("a")); + assert_eq!(credential.extra(&tokens(&["a", "b"]).tokens), tokens(&["b"]).tokens); + } + + /// An offered version without a SETUP token needs the credential in the query, and the + /// same value rides SETUP too. + #[test] + fn an_older_version_puts_the_credential_in_the_url() { + let url: Url = "https://relay.example.com/room?x=1".parse().unwrap(); + let credential = Credential::new(&url, &moq_net::Versions::all(), &tokens(&["a b"])); + assert_eq!(credential.url.as_str(), "https://relay.example.com/room?x=1&jwt=a+b"); + assert_eq!(credential.setup.as_ref().map(Token::as_str), Some("a b")); + assert!(credential.extra(&tokens(&["a b"]).tokens).is_empty()); + } + + /// A URL credential stays the connection credential, so every token rides AUTH, + /// except one equal to it, which would be granted twice. + #[test] + fn a_url_credential_sends_every_token_on_auth() { + let url: Url = "moqt://relay.example.com/room?jwt=peer".parse().unwrap(); + let credential = Credential::new(&url, &moq_net::Versions::all(), &tokens(&["a", "peer", "a"])); + assert_eq!(credential.url, url); + assert_eq!(credential.setup, None); + assert_eq!( + credential.extra(&tokens(&["a", "peer", "a"]).tokens), + tokens(&["a"]).tokens + ); + } + + #[test] + fn no_tokens_present_nothing() { + let url: Url = "https://relay.example.com/room".parse().unwrap(); + let credential = Credential::new(&url, &moq_net::Versions::all(), &Set::default()); + assert_eq!(credential.url, url); + assert_eq!(credential.setup, None); + } + + /// A unix socket's request target lives in `?path=`, so the credential goes inside it. + #[test] + fn a_unix_credential_joins_the_path_target() { + for (url, expected) in [ + ("unix:///tmp/moq.sock?path=%2Froom", "/room?jwt=a"), + ("unix:///tmp/moq.sock?path=%2Froom%3Fx%3D1", "/room?x=1&jwt=a"), + ("unix:///tmp/moq.sock", "?jwt=a"), + ] { + let url: Url = url.parse().unwrap(); + let credential = Credential::new(&url, &moq_net::Versions::all(), &tokens(&["a"])); + let path = credential + .url + .query_pairs() + .find(|(k, _)| k == "path") + .map(|(_, v)| v.into_owned()); + assert_eq!(path.as_deref(), Some(expected), "{url}"); + // The rewritten target is read back as already carrying the credential. + assert_eq!(jwt(&credential.url).as_deref(), Some("a")); + } + } + + #[test] + fn a_token_never_logs_its_value() { + let token: Token = "secret".parse().unwrap(); + assert_eq!(format!("{token:?}"), "Token(<6 bytes>)"); + assert!("".parse::().is_err()); + } + + #[test] + fn set_bumps_the_generation_only_on_change() { + let tokens = Tokens::new(vec!["a".parse().unwrap()]); + tokens.set(vec!["a".parse().unwrap()]); + assert_eq!(tokens.snapshot().generation, 0); + tokens.set(vec!["b".parse().unwrap()]); + assert_eq!(tokens.snapshot().generation, 1); + } +} diff --git a/rs/moq-tokio/src/connection.rs b/rs/moq-tokio/src/connection.rs index 5d5c2d62f2f..aa02e163101 100644 --- a/rs/moq-tokio/src/connection.rs +++ b/rs/moq-tokio/src/connection.rs @@ -1,5 +1,7 @@ //! The reconnect loop around a session, and the [`Monitor`] that observes it. +mod tokens; + use std::task::{Poll, ready}; use std::time::Duration; @@ -33,6 +35,15 @@ fn attempt_timeout(index: usize, total: usize) -> Option { (index + 1 < total).then_some(CONNECT_ATTEMPT) } +/// Whether `err` refused the credential a dial presented while the client has since been +/// given new tokens, which earns another dial instead of ending the loop. It never +/// retries a rejected credential: a refusal of unchanged tokens stays terminal. +fn refreshed(err: &Error, client: &Client, generation: u64) -> bool { + let refused = err.is_auth() + || matches!(err, Error::MoqNet(err) if moq_net::SessionError::from(err) == moq_net::SessionError::Unauthorized); + refused && client.tokens().snapshot().generation != generation +} + /// Whether a failed candidate is the peer's answer for every candidate, ending the walk. /// /// An auth rejection arrives as [`Error::Connect`] with its status already consumed, so it @@ -1083,6 +1094,8 @@ impl Connection { let mut addrs = addrs; // An old session kept alive after a GOAWAY so its in-flight groups finish. let mut draining: Option = None; + // The configured tokens beside each dial's connection credential, held until this loop ends. + let held = tokens::Holder::new(shared.auth.clone(), client.tokens().consume()); // The current run of migrations, to tell a redirect loop from a healthy handover. let mut redirects = Redirects::default(); @@ -1096,7 +1109,9 @@ impl Connection { return Err(err.into()); } - match Self::dial_any(shared, &client, &addrs, &mut draining).await { + // Read once per dial, so a refusal is judged against what this dial presented. + let tokens = client.tokens().snapshot(); + match Self::dial_any(shared, &client, &addrs, &mut draining, &tokens, &held).await { Ok((addr, dialed)) => { let url = addr.url().clone(); tracing::info!(peer = %Endpoint(&url), transport = %dialed.transport, "connected"); @@ -1108,6 +1123,11 @@ impl Connection { // persistent producers meanwhile so consumers track the live stats across the // connection, and draining any predecessor left over from a migration. let ended = run_session(shared, &session, &mut draining, client.restart).await; + // A refusal is terminal unless the tokens changed since this dial read them. + let refreshed = match &ended { + Ended::Closed(Err(err)) => refreshed(err, &client, tokens.generation), + _ => false, + }; let tenure = connected.elapsed(); @@ -1240,6 +1260,7 @@ impl Connection { // the session lived. A new connection cannot repair either one. if let Ended::Closed(Err(err)) = &ended && err.is_terminal() + && !refreshed { return Err(err.clone()); } @@ -1293,7 +1314,7 @@ impl Connection { } } Err(err) => { - if err.is_terminal() || !client.reconnect { + if (err.is_terminal() && !refreshed(&err, &client, tokens.generation)) || !client.reconnect { return Err(err); } shared.retrying(&err); @@ -1333,6 +1354,8 @@ impl Connection { client: &Client, addrs: &Addrs, draining: &mut Option, + tokens: &crate::connect::Set, + held: &tokens::Holder, ) -> crate::Result<(crate::connect::Addr, crate::client::Dialed)> { let candidates = addrs.as_slice(); let mut last = None; @@ -1342,7 +1365,12 @@ impl Connection { let url = addr.url(); tracing::info!(peer = %Endpoint(url), "connecting"); - let mut dial = std::pin::pin!(client.dial(addr.clone())); + // Held before the dial starts its session's driver, so a request the session + // sends early waits on their answers rather than being refused outside the + // connection credential's grant. + let credential = client.credential(url, tokens); + held.dialed(&credential); + let mut dial = std::pin::pin!(client.dial(addr.clone(), &credential)); let dialed = kio::wait(|waiter| { if poll_draining(draining, waiter) { shared.disconnected(); @@ -2648,6 +2676,10 @@ mod tests { } } + fn held(shared: &Shared, client: &Client) -> super::tokens::Holder { + super::tokens::Holder::new(shared.auth.clone(), client.tokens().consume()) + } + /// The first address that answers wins, however many dead ones precede it. /// This is what keeps a peer reachable when discovery advertises an interface /// that doesn't route from here ahead of the one that does. @@ -2666,7 +2698,14 @@ mod tests { let (connected, _session) = tokio::time::timeout( WALK_TIMEOUT, - Connection::dial_any(&shared, &client, &addrs, &mut draining), + Connection::dial_any( + &shared, + &client, + &addrs, + &mut draining, + &Default::default(), + &held(&shared, &client), + ), ) .await .expect("the walk must not hang") @@ -2688,7 +2727,14 @@ mod tests { let result = tokio::time::timeout( WALK_TIMEOUT, - Connection::dial_any(&shared, &client, &addrs, &mut draining), + Connection::dial_any( + &shared, + &client, + &addrs, + &mut draining, + &Default::default(), + &held(&shared, &client), + ), ) .await .expect("the walk must not hang"); @@ -2716,7 +2762,16 @@ mod tests { let shared = shared(); let mut draining = None; // `Session` isn't `Debug`, so unwrap the failure by hand. - let Err(err) = Connection::dial_any(&shared, &client, &addrs, &mut draining).await else { + let Err(err) = Connection::dial_any( + &shared, + &client, + &addrs, + &mut draining, + &Default::default(), + &held(&shared, &client), + ) + .await + else { panic!("a dead address must fail"); }; @@ -3515,6 +3570,107 @@ mod tests { } } + /// A configured token is held before its dial starts the session, so a broadcast + /// published before the dial, which only that token's grant covers, waits for its + /// answer instead of closing the session against the connection credential's grant. + #[cfg(feature = "tcp")] + #[tokio::test(start_paused = true)] + async fn a_publication_waits_for_a_configured_token() { + let shared = shared(); + let publish = crate::origin::spawn(); + let broadcast = publish.create_broadcast("room").unwrap(); + broadcast.announce(moq_net::origin::Route::default()).unwrap(); + let server_origin = crate::origin::spawn(); + + let config = crate::connect::Config { + tokens: vec!["connection".parse().unwrap(), "publish".parse().unwrap()], + ..Default::default() + }; + let mut client = config.init(Default::default()).expect("build client"); + let auth = shared.auth.clone(); + let (publish_dial, server_dial) = (publish.clone(), server_origin.clone()); + client.dialer = Some(std::sync::Arc::new(move |_addr: crate::connect::Addr| { + let (auth, publish, server_origin) = (auth.clone(), publish_dial.clone(), server_dial.clone()); + Box::pin(async move { + let versions = moq_net::Versions::from("moq-lite-07-wip".parse::().unwrap()); + let config = || { + let mut config = qmux::Config::new(qmux::Version::QMux01); + config.protocol = + qmux::Protocol::Negotiate(versions.alpns().iter().map(|alpn| alpn.to_string()).collect()); + config + }; + let (client, server) = tokio::io::duplex(64 * 1024); + let (client, server) = tokio::try_join!( + qmux::Session::connect(qmux::transport::Stream::new(client), config()), + qmux::Session::accept(qmux::transport::Stream::new(server), config()) + ) + .unwrap(); + let now = moq_net::time::Instant::now(); + let moq = moq_net::Client::new() + .with_versions(versions.clone()) + .with_auth(auth.clone()) + .with_publisher(&publish); + let client = moq.connect(now, client); + let server = async { + let mut request = moq_net::Server::new() + .with_versions(versions.clone()) + .with_subscriber(server_origin) + .accept_request(now, server) + .await + .unwrap(); + // The connection credential grants nothing; only the configured token + // covers the broadcast. + let mut requests = request.requests(); + let (session, driver) = request.ok().await.unwrap(); + tokio::spawn(moq_net::time::run(driver)); + tokio::spawn(async move { + let _session = session; + let mut issued = Vec::new(); + while let Some(request) = requests.next().await { + let grant = match request.token().is_empty() { + true => moq_net::auth::Grant::default(), + false => moq_net::auth::Grant::all(), + }; + issued.push(request.accept(grant)); + } + }); + }; + let client = async { + let (session, driver) = client.await.unwrap(); + tokio::spawn(moq_net::time::run(driver)); + session + }; + let (session, ()) = tokio::join!(client, server); + // Leave the session the time a multithreaded runtime may give it before the dial + // returns: on the paused clock, every exchange the session can make lands first, + // the connection credential's answer included. + tokio::time::sleep(Duration::from_millis(10)).await; + Ok(crate::client::Dialed { + session, + transport: crate::Transport::Tcp, + }) + }) + })); + + let addrs = Addrs::new("moqt://relay.example.com/".parse::().unwrap()); + let held = held(&shared, &client); + let tokens = client.tokens().snapshot(); + let (_, dialed) = Connection::dial_any(&shared, &client, &addrs, &mut None, &tokens, &held) + .await + .expect("the dial failed"); + + let mut announced = server_origin.consume().announced(); + let arrived = moq_clock::timeout(Duration::from_secs(5), announced.next()) + .await + .expect("the broadcast never reached the server") + .expect("the server's origin closed"); + assert!(matches!(arrived, moq_net::announce::Event::Start(update) if update.prefix.as_str() == "room")); + assert!( + futures::FutureExt::now_or_never(dialed.session.closed()).is_none(), + "the session closed before the configured token was answered" + ); + } + #[cfg(feature = "tcp")] const INITIAL: Duration = Duration::from_millis(100); #[cfg(feature = "tcp")] diff --git a/rs/moq-tokio/src/connection/tokens.rs b/rs/moq-tokio/src/connection/tokens.rs new file mode 100644 index 00000000000..b264d8cb626 --- /dev/null +++ b/rs/moq-tokio/src/connection/tokens.rs @@ -0,0 +1,382 @@ +//! The configured tokens a connection holds on its auth handle, beside each dial's +//! connection credential. + +use std::sync::{Arc, Mutex}; +use std::task::Poll; + +use bytes::Bytes; +use futures::FutureExt; +use moq_net::{auth, kio}; + +use crate::abort::AbortOnDrop; +use crate::connect::{Credential, Set, Token}; + +type Opening = futures::future::BoxFuture<'static, moq_net::Result<(auth::Auth, auth::Answer)>>; + +/// Holds every configured token except the latest dial's connection credential on the +/// connection's auth handle, which presents them on each session it builds, and keeps +/// them in step with the client's [`crate::connect::Tokens`]. +pub(super) struct Holder { + shared: Arc, + _task: AbortOnDrop, +} + +struct Shared { + state: Mutex, + dialed: tokio::sync::Notify, +} + +struct Holding { + auth: auth::Handle, + tokens: Vec, + /// `None` until the first dial, so a token that becomes the connection credential is + /// never also presented in band. + credential: Option, + held: Held, +} + +impl Holder { + pub fn new(auth: auth::Handle, tokens: kio::Consumer) -> Self { + let set = (*tokens.read()).clone(); + let shared = Arc::new(Shared { + state: Mutex::new(Holding { + auth, + tokens: set.tokens, + credential: None, + held: Held::default(), + }), + dialed: tokio::sync::Notify::new(), + }); + let task = tokio::spawn(run(shared.clone(), tokens, set.generation)); + Self { + shared, + _task: AbortOnDrop::new(task), + } + } + + /// Hold the tokens `credential` leaves over. Call it before the dial that presents + /// `credential` starts: the new tokens register before this returns, so the session + /// presents them from its start. + pub fn dialed(&self, credential: &Credential) { + let mut state = self.shared.state.lock().unwrap(); + state.credential = Some(credential.clone()); + state.held.unsupported = false; + state.update(); + drop(state); + self.shared.dialed.notify_one(); + } +} + +impl Holding { + fn update(&mut self) { + let Some(credential) = &self.credential else { return }; + let extra = credential.extra(&self.tokens); + self.held.update(&self.auth, extra); + } +} + +async fn run(shared: Arc, tokens: kio::Consumer, mut generation: u64) { + loop { + let changed = async { + let wait = tokens.wait(|set| match set.generation != generation { + true => Poll::Ready((set.tokens.clone(), set.generation)), + false => Poll::Pending, + }); + // Every client handle dropped, so the tokens can no longer change. + match wait.await { + Ok(changed) => changed, + Err(_) => std::future::pending().await, + } + }; + let answered = std::future::poll_fn(|cx| shared.state.lock().unwrap().held.poll_answers(cx)); + tokio::select! { + biased; + (latest, next) = changed => { + generation = next; + let mut state = shared.state.lock().unwrap(); + state.tokens = latest; + state.update(); + } + // A dial added tokens to wait on. + _ = shared.dialed.notified() => {} + _ = answered => {} + } + } +} + +/// The configured tokens held on their own AUTH streams. +#[derive(Default)] +struct Held { + tokens: Vec<(Token, Presented)>, + /// Tokens a change replaced, answered or not, kept presented until every + /// replacement has its answer so the grant never narrows in between. + retiring: Vec, + /// Whether the latest dial already logged that its session carries no extra tokens. + unsupported: bool, +} + +enum Presented { + Waiting(Opening), + Granted(#[allow(dead_code, reason = "held to keep the token presented")] auth::Auth), + /// Refused or unsupported. Not presented again unless a change removes and re-adds it. + Refused, +} + +impl Held { + fn update(&mut self, handle: &auth::Handle, tokens: Vec) { + let mut old = std::mem::take(&mut self.tokens); + for token in tokens { + let answer = match old.iter().position(|(held, _)| *held == token) { + Some(index) => old.swap_remove(index).1, + None => { + let handle = handle.clone(); + let bytes = Bytes::from(token.as_str().to_string()); + let mut opening = async move { handle.add(bytes).await }.boxed(); + // The first poll registers the token synchronously. + if let Some(result) = opening.as_mut().now_or_never() { + opening = futures::future::ready(result).boxed(); + } + Presented::Waiting(opening) + } + }; + self.tokens.push((token, answer)); + } + // A waiting registration may already hold its grant, unread, so it retires too. + self.retiring.extend( + old.into_iter() + .map(|(_, answer)| answer) + .filter(|answer| !matches!(answer, Presented::Refused)), + ); + self.settle(); + } + + /// Resolve when another token has its answer. + fn poll_answers(&mut self, cx: &mut std::task::Context<'_>) -> Poll<()> { + let mut answered = false; + let mut unsupported = false; + for (_, answer) in &mut self.tokens { + let Presented::Waiting(opening) = answer else { continue }; + let Poll::Ready(result) = opening.as_mut().poll(cx) else { + continue; + }; + answered = true; + *answer = match result { + Ok((auth, answer)) => { + // Without AUTH, moq-transport takes a token only for the requests that name + // it, but a configured token rides no request, so this session sends it + // nowhere. The handle still presents it on a later session that has AUTH. + if answer == auth::Answer::Requests { + unsupported = true; + } + Presented::Granted(auth) + } + Err(moq_net::Error::Unsupported) => { + unsupported = true; + Presented::Refused + } + Err(err) => { + tracing::warn!(%err, "a configured token was refused"); + Presented::Refused + } + }; + } + if unsupported && !std::mem::replace(&mut self.unsupported, true) { + tracing::debug!("the peer takes no tokens in band; presenting only the connection credential"); + } + self.settle(); + match answered { + true => Poll::Ready(()), + false => Poll::Pending, + } + } + + /// Withdraw the replaced tokens once nothing is waiting on an answer. + fn settle(&mut self) { + if !self + .tokens + .iter() + .any(|(_, answer)| matches!(answer, Presented::Waiting(_))) + { + self.retiring.clear(); + } + } +} + +#[cfg(all(test, feature = "tcp"))] +mod tests { + use super::*; + + struct Pair { + _client: moq_net::Session, + auth: auth::Handle, + requests: tokio::sync::mpsc::UnboundedReceiver, + _tasks: Vec>, + _acceptor: AbortOnDrop, + } + + async fn pair_on(version: &str) -> Pair { + let versions = moq_net::Versions::from(version.parse::().unwrap()); + let config = || { + let mut config = qmux::Config::new(qmux::Version::QMux01); + config.protocol = qmux::Protocol::Negotiate(versions.alpns().iter().map(|alpn| alpn.to_string()).collect()); + config + }; + let stream = |io| qmux::transport::Stream::new(io); + let (client, server) = tokio::io::duplex(64 * 1024); + let (client, server) = tokio::try_join!( + qmux::Session::connect(stream(client), config()), + qmux::Session::accept(stream(server), config()) + ) + .unwrap(); + let now = moq_net::time::Instant::now(); + let handle = auth::Handle::new(); + let client = async { + let (session, driver) = moq_net::Client::new() + .with_versions(versions.clone()) + .with_auth(handle.clone()) + .connect(now, client) + .await + .unwrap(); + (session, AbortOnDrop::new(tokio::spawn(moq_net::time::run(driver)))) + }; + let server = async { + let mut request = moq_net::Server::new() + .with_versions(versions.clone()) + .accept_request(now, server) + .await + .unwrap(); + let mut requests = request.requests(); + let (session, driver) = request.ok().await.unwrap(); + let (sender, receiver) = tokio::sync::mpsc::unbounded_channel(); + let acceptor = tokio::spawn(async move { + let _session = session; + let mut initial = Vec::new(); + while let Some(request) = requests.next().await { + if request.token().is_empty() { + initial.push(request.accept(auth::Grant::default())); + } else if sender.send(request).is_err() { + break; + } + } + }); + ( + receiver, + AbortOnDrop::new(tokio::spawn(moq_net::time::run(driver))), + AbortOnDrop::new(acceptor), + ) + }; + let ((client, client_task), (requests, server_task, acceptor)) = tokio::join!(client, server); + Pair { + _client: client, + auth: handle, + requests, + _tasks: vec![client_task, server_task], + _acceptor: acceptor, + } + } + + /// A holder for `tokens` on a pair's client, dialed with the credential a lite-07 dial + /// to `url` would present. + fn holder(pair: &Pair, url: &str, tokens: &[&str]) -> (crate::connect::Tokens, Holder) { + let tokens = crate::connect::Tokens::new(tokens.iter().map(|token| token.parse().unwrap()).collect()); + let versions = moq_net::Versions::from("moq-lite-07-wip".parse::().unwrap()); + let credential = Credential::new(&url.parse().unwrap(), &versions, &tokens.snapshot()); + let holder = Holder::new(pair.auth.clone(), tokens.consume()); + holder.dialed(&credential); + (tokens, holder) + } + + async fn next_token(pair: &mut Pair) -> auth::Request { + moq_clock::timeout(std::time::Duration::from_secs(5), pair.requests.recv()) + .await + .expect("no token presented") + .unwrap() + } + + /// Every configured token but the connection credential rides its own AUTH stream. + #[tokio::test(start_paused = true)] + async fn configured_tokens_ride_auth_beside_the_connection_credential() { + let mut pair = pair_on("moq-lite-07-wip").await; + let (_tokens, _holder) = holder(&pair, "moqt://relay.example.com/", &["connection", "a", "b"]); + let mut presented = Vec::new(); + let mut issued = Vec::new(); + for _ in 0..2 { + let request = next_token(&mut pair).await; + presented.push(request.token().to_vec()); + issued.push(request.accept(auth::Grant::all())); + } + presented.sort(); + assert_eq!(presented, [b"a".to_vec(), b"b".to_vec()]); + let again = moq_clock::timeout(std::time::Duration::from_secs(1), pair.requests.recv()).await; + assert!(again.is_err(), "the connection credential was presented twice"); + } + + /// A URL that carries its own credential sends every configured token in band. + #[tokio::test(start_paused = true)] + async fn a_url_credential_sends_every_configured_token_in_band() { + let mut pair = pair_on("moq-lite-07-wip").await; + let (_tokens, _holder) = holder(&pair, "moqt://relay.example.com/?jwt=peer", &["a"]); + let request = next_token(&mut pair).await; + assert_eq!(request.token().as_ref(), b"a"); + } + + /// A replaced token is withdrawn only once its replacement has an answer, so the grant + /// never narrows in between. + #[tokio::test(start_paused = true)] + async fn a_replaced_token_is_withdrawn_after_its_replacement_is_answered() { + let mut pair = pair_on("moq-lite-07-wip").await; + let (tokens, _holder) = holder(&pair, "moqt://relay.example.com/", &["connection", "old"]); + let old = next_token(&mut pair).await.accept(auth::Grant::all()); + + tokens.set(vec!["connection".parse().unwrap(), "new".parse().unwrap()]); + let request = next_token(&mut pair).await; + assert_eq!(request.token().as_ref(), b"new"); + let early = moq_clock::timeout(std::time::Duration::from_secs(1), old.closed()).await; + assert!(early.is_err(), "withdrawn before its replacement was answered"); + + let new = request.accept(auth::Grant::all()); + moq_clock::timeout(std::time::Duration::from_secs(5), old.closed()) + .await + .expect("the replaced token was never withdrawn"); + let kept = moq_clock::timeout(std::time::Duration::from_secs(1), new.closed()).await; + assert!(kept.is_err(), "the replacement was withdrawn"); + } + + /// A replacement is authoritative: the replaced token is withdrawn once the peer + /// answers its replacement, even with a refusal. + #[tokio::test(start_paused = true)] + async fn a_refused_replacement_still_withdraws_the_replaced_token() { + let mut pair = pair_on("moq-lite-07-wip").await; + let (tokens, _holder) = holder(&pair, "moqt://relay.example.com/", &["connection", "old"]); + let old = next_token(&mut pair).await.accept(auth::Grant::all()); + + tokens.set(vec!["connection".parse().unwrap(), "new".parse().unwrap()]); + let request = next_token(&mut pair).await; + assert_eq!(request.token().as_ref(), b"new"); + request.reject(moq_net::SessionError::Unauthorized, "expired"); + moq_clock::timeout(std::time::Duration::from_secs(5), old.closed()) + .await + .expect("the replaced token outlived its refused replacement"); + } + + /// Without AUTH a configured token rides nothing on this session, which logs that once, + /// but the handle keeps it for a later session that has AUTH. + #[tokio::test(start_paused = true)] + async fn a_configured_token_without_auth_is_kept_for_a_later_session() { + let pair = pair_on("moq-transport-14").await; + let mut held = Held::default(); + held.update(&pair.auth, vec!["a".parse().unwrap(), "b".parse().unwrap()]); + moq_clock::timeout( + std::time::Duration::from_secs(5), + std::future::poll_fn(|cx| held.poll_answers(cx)), + ) + .await + .expect("no answer"); + assert!( + held.tokens + .iter() + .all(|(_, answer)| matches!(answer, Presented::Granted(_))) + ); + assert!(held.unsupported); + } +} diff --git a/rs/moq-tokio/src/settings.rs b/rs/moq-tokio/src/settings.rs index 2c5f2d59de2..3b2c565674f 100644 --- a/rs/moq-tokio/src/settings.rs +++ b/rs/moq-tokio/src/settings.rs @@ -138,6 +138,9 @@ pub struct Connect { #[usage(env = "MOQ_CONNECT", cli("--connect"))] url: Option, + #[usage(env = "MOQ_CONNECT_TOKEN", cli("--connect-token"), parse = "list_by_comma")] + tokens: Option>, + #[usage(env = "MOQ_CONNECT_BIND", cli("--connect-bind"))] bind: Option, diff --git a/rs/moq-tokio/tests/integration/main.rs b/rs/moq-tokio/tests/integration/main.rs index e23e62e0fd2..9e84d9fb696 100644 --- a/rs/moq-tokio/tests/integration/main.rs +++ b/rs/moq-tokio/tests/integration/main.rs @@ -18,6 +18,7 @@ mod reconnect; mod reset_secret; mod stream_code; mod subscription_end_integrity; +mod tokens; mod unannounce; mod worker; diff --git a/rs/moq-tokio/tests/integration/tokens.rs b/rs/moq-tokio/tests/integration/tokens.rs new file mode 100644 index 00000000000..c8b095123b1 --- /dev/null +++ b/rs/moq-tokio/tests/integration/tokens.rs @@ -0,0 +1,166 @@ +//! Configured tokens reach the server as the connection credential: in SETUP where the +//! negotiated version carries one, and in the URL query where it might not. A refused +//! credential is dialed again only once the client has a new one. + +#![cfg(feature = "quic")] + +use std::{sync::Arc, time::Duration}; + +use moq_tokio::moq_net; + +/// What the server saw on one handshake. +#[derive(Debug, PartialEq)] +struct Seen { + query: Option, + token: Option, +} + +struct Server { + port: u16, + seen: tokio::sync::mpsc::UnboundedReceiver, + /// Releases a held `refused` handshake to be rejected. + release: Arc, +} + +/// Serve `version`, rejecting as unauthorized any handshake presenting `refused`, once +/// [`Server::release`] lets it go. +async fn serve(version: &str, refused: &'static str) -> Server { + let mut config = moq_tokio::server::Config::default(); + config.listen.bind = Some("127.0.0.1:0".parse().unwrap()); + config.listen.tls.generate = vec!["localhost".into()]; + config.listen.version = vec![version.parse().unwrap()]; + let mut listener = config + .init() + .expect("failed to init server") + .listen() + .await + .expect("failed to listen"); + let port = listener.local_addr().expect("no quic addr").port(); + let (sender, seen) = tokio::sync::mpsc::unbounded_channel(); + let release = Arc::new(tokio::sync::Notify::new()); + + let notify = release.clone(); + tokio::spawn(async move { + while let Some(request) = listener.accept().await { + let seen = Seen { + query: request.query().map(str::to_string), + token: request + .token() + .map(|token| String::from_utf8(token.value.clone()).unwrap()), + }; + let rejected = seen.token.as_deref() == Some(refused) + || seen + .query + .as_deref() + .is_some_and(|query| query.contains(&format!("jwt={refused}"))); + sender.send(seen).ok(); + let notify = notify.clone(); + tokio::spawn(async move { + if rejected { + notify.notified().await; + request.reject(moq_net::SessionError::Unauthorized).await.ok(); + return; + } + let Ok(_session) = request.ok().await else { + return; + }; + // Hold the session until the test's runtime shuts down. + std::future::pending::<()>().await; + }); + } + }); + + Server { port, seen, release } +} + +fn client(version: &str, tokens: &[&str]) -> moq_tokio::Client { + let mut config = moq_tokio::connect::Config::default(); + config.tls.insecure = Some(true); + config.version = vec![version.parse().unwrap()]; + config.tokens = tokens.iter().map(|token| token.parse().unwrap()).collect(); + config.init(Default::default()).expect("failed to init client") +} + +async fn next(server: &mut Server) -> Seen { + tokio::time::timeout(Duration::from_secs(10), server.seen.recv()) + .await + .expect("no handshake arrived") + .unwrap() +} + +/// lite-07 carries the credential in SETUP, so the URL stays as the caller wrote it. +#[tokio::test] +async fn lite_07_presents_the_token_in_setup() { + let mut server = serve("moq-lite-07-wip", "refused").await; + let client = client("moq-lite-07-wip", &["credential"]); + let url: url::Url = format!("moqt://localhost:{}/room", server.port).parse().unwrap(); + let _connection = client.connect(url); + assert_eq!( + next(&mut server).await, + Seen { + query: None, + token: Some("credential".into()), + } + ); +} + +/// lite-05 has no SETUP token, so the credential rides the request target's query. +#[tokio::test] +async fn lite_05_presents_the_token_in_the_url() { + let mut server = serve("moq-lite-05", "refused").await; + let client = client("moq-lite-05", &["credential"]); + let url: url::Url = format!("moqt://localhost:{}/room", server.port).parse().unwrap(); + let _connection = client.connect(url); + assert_eq!( + next(&mut server).await, + Seen { + query: Some("jwt=credential".into()), + token: None, + } + ); +} + +/// A refused credential the client has since replaced is dialed again with the new one. +#[tokio::test] +async fn a_refused_credential_redials_with_its_replacement() { + let mut server = serve("moq-lite-07-wip", "old").await; + let client = client("moq-lite-07-wip", &["old"]); + let url: url::Url = format!("moqt://localhost:{}/", server.port).parse().unwrap(); + let connection = client.connect(url); + assert_eq!(next(&mut server).await.token.as_deref(), Some("old")); + + client.tokens().set(vec!["new".parse().unwrap()]); + server.release.notify_one(); + assert_eq!(next(&mut server).await.token.as_deref(), Some("new")); + let _connection = tokio::time::timeout(Duration::from_secs(10), connection.established()) + .await + .expect("the replacement never connected") + .expect("the replacement was refused"); +} + +/// Without a new credential, a refusal ends the connection rather than retrying it. +#[tokio::test] +async fn an_unchanged_refused_credential_is_terminal() { + let mut server = serve("moq-lite-07-wip", "old").await; + let client = client("moq-lite-07-wip", &["old"]); + let url: url::Url = format!("moqt://localhost:{}/", server.port).parse().unwrap(); + let connection = client.connect(url); + assert_eq!(next(&mut server).await.token.as_deref(), Some("old")); + server.release.notify_one(); + // A lite client is live before the server's answer, so the refusal may close it after. + let err = tokio::time::timeout(Duration::from_secs(10), async { + match connection.established().await { + Ok(connection) => connection.closed().await.expect_err("closed without an error"), + Err(err) => err, + } + }) + .await + .expect("the refusal never ended the connection"); + assert!( + matches!( + err, + moq_tokio::Error::MoqNet(moq_net::Error::Session(moq_net::SessionError::Unauthorized)) + ), + "{err:?}" + ); +} diff --git a/swift/Sources/Moq/Client.swift b/swift/Sources/Moq/Client.swift index 22748d36463..c28e636fbd6 100644 --- a/swift/Sources/Moq/Client.swift +++ b/swift/Sources/Moq/Client.swift @@ -11,6 +11,11 @@ public final class Client: Sendable { /// - bind: The local UDP address to bind; `nil` binds an ephemeral dual-stack port. /// - versions: Protocol versions to offer, most preferred first (`"moq-lite-03"`); /// empty offers every supported version, which `versions()` lists. + /// - tokens: Credentials every dial presents. The first is the connection credential, + /// sent in SETUP and, while an offered version can't carry it there, the URL's `?jwt=`; + /// each other one widens the session's grant over in-band AUTH. A URL that already + /// carries `?jwt=` keeps it, and then every token rides AUTH. Replace them through + /// `tokens()`. /// - timeout: How long one connect may take, dial and handshake together; 0 waits forever. /// - race: Happy Eyeballs: how long before the next resolved address is also dialed. /// - resolutionDelay: Happy Eyeballs: how long IPv4 waits for the full DNS answer. @@ -28,6 +33,7 @@ public final class Client: Sendable { public init( bind: String? = nil, versions: [String] = [], + tokens: [String] = [], timeout: TimeInterval? = nil, race: TimeInterval? = nil, resolutionDelay: TimeInterval? = nil, @@ -42,6 +48,7 @@ public final class Client: Sendable { ffi = try MoqClient(config: MoqClientConfig( bind: bind, versions: versions, + tokens: tokens, timeout: timeout, race: race, resolutionDelay: resolutionDelay, @@ -61,6 +68,11 @@ public final class Client: Sendable { Session(try await ffi.connect(url: url)) } + /// The credentials every dial presents, to replace while running. + public func tokens() -> Tokens { + Tokens(ffi.tokens()) + } + /// Cancel all current and future `connect()` calls. public func cancel() { ffi.cancel() @@ -69,6 +81,25 @@ public final class Client: Sendable { } +/// The credentials a `Client` presents, replaceable while it runs. +public final class Tokens: Sendable { + let ffi: MoqTokens + + init(_ ffi: MoqTokens) { + self.ffi = ffi + } + + /// Replace the credentials every dial presents, such as before the current ones expire. + /// + /// The next dial presents them, and a live session with in-band AUTH presents them at + /// once, withdrawing each replaced token once its replacements are answered, even when + /// one is refused. After an authorization refusal the client redials only if they changed. + /// - Throws: `MoqError.Config` for an empty token. + public func set(_ tokens: [String]) throws { + try ffi.set(tokens: tokens) + } +} + /// The protocol versions offered by default, most preferred first, spelled the way /// `Client(versions:)` takes them. public func versions() -> [String] { diff --git a/test/interop/interop.sh b/test/interop/interop.sh index 12be4f07086..04680dcf871 100755 --- a/test/interop/interop.sh +++ b/test/interop/interop.sh @@ -893,6 +893,17 @@ token_url() { printf '%s/?jwt=%s' "$URL" "$token" } +# Set `connect` to moq-cli's arguments for , moving a `?jwt=` token to +# `--connect-token` so it rides SETUP where the version carries one. The binding clients keep it +# in the URL, which covers the query form. +# shellcheck disable=SC2329 # invoked from the indirectly invoked publisher and subscriber +rust_connect() { + case "$1" in + *'?jwt='*) connect=(--connect "${1%%\?jwt=*}" --connect-token "${1#*\?jwt=}") ;; + *) connect=(--connect "$1") ;; + esac +} + # The pattern a cell's token grants its publisher, or its subscribers, on . The `auto` # pass mints patterns no prefix could carry, so AUTH must deliver them as minted. moq-transport's # AUTH extension (draft-17 on) carries prefixes alone and refuses a narrower grant, closing the @@ -1013,15 +1024,17 @@ run_publisher() { # A finished import stays up for its linger (a released CLI: its max-age); # `stop_publisher` allows 10 s to close. The wire-compat FETCH lanes keep the # video-only Annex-B input. + local connect + rust_connect "$url" if [[ "$AUDIO" -eq 1 ]]; then # The max-age caps every reader's max delay, so it covers the longest, the native # JS audio readers' 5 s. Each 1 s fragment lands as one burst, so with a 1 s # max-age the oldest group a joining reader is served goes stale as soon as the # next group lands, cutting it mid-read and failing the gap check. - ffmpeg_fmp4 "$tone" "$(publisher_codecs rust)" | RUST_LOG="$CLI_LOG" "$MOQ" --connect "$url" --connect-version "$(cell_version rust)" \ + ffmpeg_fmp4 "$tone" "$(publisher_codecs rust)" | RUST_LOG="$CLI_LOG" "$MOQ" "${connect[@]}" --connect-version "$(cell_version rust)" \ --broadcast "$broadcast" import --max-age 5s fmp4 else - ffmpeg_h264 | RUST_LOG="$CLI_LOG" "$MOQ" --connect "$url" --connect-version "$(cell_version rust)" \ + ffmpeg_h264 | RUST_LOG="$CLI_LOG" "$MOQ" "${connect[@]}" --connect-version "$(cell_version rust)" \ --broadcast "$broadcast" import --max-age 1s avc3 fi ;; @@ -1308,13 +1321,14 @@ PYCODE fi # The CLI has no PCM output, so moq-audio decodes the audio in a client of its own, # alongside the CLI so the cell stays within one timeout. - local n audio_pid="" audio_status=0 + local n connect audio_pid="" audio_status=0 + rust_connect "$url" if [[ "$AUDIO" -eq 1 ]]; then timeout -k 3 "$TIMEOUT" "$TARGET_BASE/$PROFILE/examples/interop-audio" \ "$url" "$broadcast" "$audio_out" "$AUDIO_MS" "$(cell_version rust)" & audio_pid=$! fi - n=$(RUST_LOG="$CLI_LOG" timeout -k 3 "$TIMEOUT" "${INTEROP_SUB_MOQ:-$MOQ}" --connect "$url" \ + n=$(RUST_LOG="$CLI_LOG" timeout -k 3 "$TIMEOUT" "${INTEROP_SUB_MOQ:-$MOQ}" "${connect[@]}" \ --connect-version "$(cell_version rust)" --broadcast "$broadcast" export fmp4 | head -c 1 | wc -c | tr -d ' ' || true) [[ -z "$audio_pid" ]] || wait "$audio_pid" || audio_status=$? [[ "${n:-0}" -ge 1 && "$audio_status" -eq 0 ]]