diff --git a/dart/moq/lib/src/aliases.dart b/dart/moq/lib/src/aliases.dart index fca341da1d5..05a882e6a27 100644 --- a/dart/moq/lib/src/aliases.dart +++ b/dart/moq/lib/src/aliases.dart @@ -23,6 +23,11 @@ typedef Client = MoqClient; /// The credentials a [Client] presents, replaceable while it runs. typedef Tokens = MoqTokens; +/// A request token a session presents, which a route or [Claim] names with +/// `setAuth`. Await `answered()` for the peer's verdict; `cancel()` withdraws it +/// unless a route or claim still names it. +typedef Credential = MoqCredential; + /// 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 849e318e440..1a08597ab73 100644 --- a/dart/moq/lib/src/client.dart +++ b/dart/moq/lib/src/client.dart @@ -279,6 +279,14 @@ final class Moq { /// `ConfigMoqException`. Await [Tokens.answered] for the relay's verdict. Tokens tokens() => session.tokens()!; + /// Present a request token, for a route or [Claim] to carry with `setAuth`. + /// + /// On moq-transport it rides each naming route's announcing request; on + /// moq-lite it needs AUTH, where it earns a grant. The session presents it + /// again on every reconnect. Await [Credential.answered] for the peer's + /// verdict. An empty token throws `ConfigMoqException`. + Credential credential(String token) => session.credential(token: token); + /// Drain within one second, then stop the client. /// /// `moq-lite-05` and newer wait for the peer to receive finished tails; older diff --git a/dart/moq/test/moq_test.dart b/dart/moq/test/moq_test.dart index 0f5d2d468a0..87cf87ddac0 100644 --- a/dart/moq/test/moq_test.dart +++ b/dart/moq/test/moq_test.dart @@ -234,6 +234,76 @@ void main() { server.cancel(); }); + test( + 'a credential is answered, names a route and a claim, and cancels', + () async { + const version = ['moq-lite-07-wip']; + final server = MoqServer( + config: MoqServerConfig( + bind: '127.0.0.1:0', + versions: version, + tls: MoqServerTls(generate: ['localhost']), + quic: MoqQuicConfig(), + ), + ); + final address = await server.listen().timeout(timeout); + final accepted = () async { + final request = await server.accept().timeout(timeout); + if (request == null) throw StateError('server closed before accept'); + return request.accept().timeout(timeout); + }(); + + final client = await Moq.connect( + 'https://$address', + options: ConnectOptions( + tls: ClientTls(insecure: true), + bind: '127.0.0.1:0', + versions: version, + once: true, + ), + ).timeout(timeout); + final serverSession = await accepted; + + expect(() => client.credential(''), throwsA(isA())); + final Credential credential = client.credential('extra'); + // The server verifies no tokens of its own, so it refuses this one. + await expectLater( + credential.answered().timeout(timeout), + throwsA(isA()), + ); + + final route = client.route('room/x', options: RouteOptions(hidden: true)); + final claim = client.session.publish().claim( + prefix: 'live', + options: RouteOptions(hidden: true), + ); + route.setAuth(credential: credential); + claim.setAuth(credential: credential); + route.setAuth(credential: null); + claim.setAuth(credential: null); + + credential.cancel(); + await expectLater( + credential.answered().timeout(timeout), + throwsA(isA()), + ); + expect( + () => route.setAuth(credential: credential), + throwsA(isA()), + ); + expect( + () => claim.setAuth(credential: credential), + throwsA(isA()), + ); + route.cancel(); + claim.cancel(); + + await client.close(); + serverSession.cancel(code: 0); + server.cancel(); + }, + ); + test('an announced instance pins request and follow', () async { // moq-lite-06 carries no instance, so the session mints one for the route. const version = ['moq-lite-06']; diff --git a/dart/moq_ffi/lib/src/moq.dart b/dart/moq_ffi/lib/src/moq.dart index aa8bfea3af5..a4881a969d3 100644 --- a/dart/moq_ffi/lib/src/moq.dart +++ b/dart/moq_ffi/lib/src/moq.dart @@ -8,7 +8,9 @@ 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"; @@ -10079,6 +10081,97 @@ class FfiConverterMoqAudioProducer { } } +abstract class MoqCredentialInterface { + UniffiRustFuture answered(); + void cancel(); +} + +final _MoqCredentialFinalizer = Finalizer>((ptr) { + rustCall((status) => uniffi_moq_ffi_fn_free_moqcredential(ptr, status)); +}); + +class MoqCredential implements MoqCredentialInterface { + late final Pointer _ptr; + bool _isDisposed = false; + MoqCredential._(this._ptr) { + _MoqCredentialFinalizer.attach(this, _ptr, detach: this); + } + factory MoqCredential.lift(Pointer ptr) { + return MoqCredential._(ptr); + } + Pointer uniffiClonePointer() { + if (_isDisposed) { + throw StateError("Object has already been disposed"); + } + return rustCall( + (status) => uniffi_moq_ffi_fn_clone_moqcredential(_ptr, status), + ); + } + + void dispose() { + if (_isDisposed) { + return; + } + final status = calloc(); + _isDisposed = true; + _MoqCredentialFinalizer.detach(this); + try { + uniffi_moq_ffi_fn_free_moqcredential(_ptr, status); + checkCallStatus(NullRustCallStatusErrorHandler(), status); + } finally { + calloc.free(status); + } + } + + UniffiRustFuture answered() { + return uniffiRustCallAsync( + () => + uniffi_moq_ffi_fn_method_moqcredential_answered(uniffiClonePointer()), + ffi_moq_ffi_rust_future_poll_void, + ffi_moq_ffi_rust_future_complete_void, + ffi_moq_ffi_rust_future_cancel_void, + ffi_moq_ffi_rust_future_free_void, + (_) {}, + moqExceptionErrorHandler, + ); + } + + void cancel() { + return rustCall((status) { + uniffi_moq_ffi_fn_method_moqcredential_cancel( + uniffiClonePointer(), + status, + ); + }, null); + } +} + +class FfiConverterMoqCredential { + static MoqCredential lift(Pointer ptr) { + return MoqCredential.lift(ptr); + } + + static Pointer lower(MoqCredential value) { + return value.uniffiClonePointer(); + } + + static int allocationSize(MoqCredential value) { + return 8; + } + + static LiftRetVal read(Uint8List buf) { + final handle = buf.buffer.asByteData(buf.offsetInBytes).getInt64(0); + final pointer = Pointer.fromAddress(handle); + return LiftRetVal(MoqCredential.lift(pointer), 8); + } + + static int write(MoqCredential value, Uint8List buf) { + final handle = lower(value); + buf.buffer.asByteData(buf.offsetInBytes).setInt64(0, handle.address); + return 8; + } +} + abstract class MoqGrantConsumerInterface { void cancel(); UniffiRustFuture changed(); @@ -13869,6 +13962,7 @@ abstract class MoqClaimInterface { void cancel(); MoqInstance instanceId(); UniffiRustFuture requested(); + void setAuth({required MoqCredential? credential}); void setCost({required int cost}); void withdraw(); } @@ -13943,6 +14037,16 @@ class MoqClaim implements MoqClaimInterface { ); } + void setAuth({required MoqCredential? credential}) { + return rustCall((status) { + uniffi_moq_ffi_fn_method_moqclaim_set_auth( + uniffiClonePointer(), + FfiConverterOptionalMoqCredential.lower(credential), + status, + ); + }, moqExceptionErrorHandler); + } + void setCost({required int cost}) { return rustCall((status) { uniffi_moq_ffi_fn_method_moqclaim_set_cost( @@ -14317,6 +14421,7 @@ abstract class MoqRouteInterface { MoqBroadcastProducer broadcast(); void cancel(); MoqInstance? instanceId(); + void setAuth({required MoqCredential? credential}); void setCost({required int cost}); void withdraw(); } @@ -14390,6 +14495,16 @@ class MoqRoute implements MoqRouteInterface { ); } + void setAuth({required MoqCredential? credential}) { + return rustCall((status) { + uniffi_moq_ffi_fn_method_moqroute_set_auth( + uniffiClonePointer(), + FfiConverterOptionalMoqCredential.lower(credential), + status, + ); + }, moqExceptionErrorHandler); + } + void setCost({required int cost}) { return rustCall((status) { uniffi_moq_ffi_fn_method_moqroute_set_cost( @@ -15840,6 +15955,7 @@ abstract class MoqSessionInterface { void cancel({required int code}); UniffiRustFuture closed(); MoqOriginConsumer consume(); + MoqCredential credential({required String token}); MoqGrantConsumer grants(); MoqOriginProducer publish(); MoqBandwidthConsumer? recvBandwidth(); @@ -15932,6 +16048,18 @@ class MoqSession implements MoqSessionInterface { ); } + MoqCredential credential({required String token}) { + return rustCallWithLifter( + (status) => uniffi_moq_ffi_fn_method_moqsession_credential( + uniffiClonePointer(), + FfiConverterString.lower(token), + status, + ), + FfiConverterMoqCredential.lift, + moqExceptionErrorHandler, + ); + } + MoqGrantConsumer grants() { return rustCallWithLifter( (status) => uniffi_moq_ffi_fn_method_moqsession_grants( @@ -18352,6 +18480,53 @@ class FfiConverterOptionalMoqCongestionControl { } } +class FfiConverterOptionalMoqCredential { + static MoqCredential? lift(RustBuffer buf) { + return FfiConverterOptionalMoqCredential.read(buf.asUint8List()).value; + } + + static LiftRetVal read(Uint8List buf) { + if (ByteData.view(buf.buffer, buf.offsetInBytes).getInt8(0) == 0) { + return LiftRetVal(null, 1); + } + final result = FfiConverterMoqCredential.read( + Uint8List.view(buf.buffer, buf.offsetInBytes + 1), + ); + return LiftRetVal(result.value, result.bytesRead + 1); + } + + static int allocationSize([MoqCredential? value]) { + if (value == null) { + return 1; + } + return FfiConverterMoqCredential.allocationSize(value) + 1; + } + + static RustBuffer lower(MoqCredential? value) { + if (value == null) { + return toRustBuffer(Uint8List.fromList([0])); + } + final buf = Uint8List( + FfiConverterOptionalMoqCredential.allocationSize(value), + ); + FfiConverterOptionalMoqCredential.write(value, buf); + return toRustBuffer(buf); + } + + static int write(MoqCredential? value, Uint8List buf) { + if (value == null) { + buf[0] = 0; + return 1; + } + buf[0] = 1; + return FfiConverterMoqCredential.write( + value, + Uint8List.view(buf.buffer, buf.offsetInBytes + 1), + ) + + 1; + } +} + class FfiConverterOptionalMoqDatagram { static MoqDatagram? lift(RustBuffer buf) { return FfiConverterOptionalMoqDatagram.read(buf.asUint8List()).value; @@ -20618,6 +20793,35 @@ external void uniffi_moq_ffi_fn_method_moqaudioproducer_write( Pointer uniffiStatus, ); +@Native Function(Pointer, Pointer)>( + assetId: _uniffiAssetId, +) +external Pointer uniffi_moq_ffi_fn_clone_moqcredential( + Pointer handle, + Pointer uniffiStatus, +); + +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external void uniffi_moq_ffi_fn_free_moqcredential( + Pointer handle, + Pointer uniffiStatus, +); + +@Native Function(Pointer)>(assetId: _uniffiAssetId) +external Pointer uniffi_moq_ffi_fn_method_moqcredential_answered( + Pointer ptr, +); + +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external void uniffi_moq_ffi_fn_method_moqcredential_cancel( + Pointer ptr, + Pointer uniffiStatus, +); + @Native Function(Pointer, Pointer)>( assetId: _uniffiAssetId, ) @@ -22253,6 +22457,15 @@ external Pointer uniffi_moq_ffi_fn_method_moqclaim_requested( Pointer ptr, ); +@Native, RustBuffer, Pointer)>( + assetId: _uniffiAssetId, +) +external void uniffi_moq_ffi_fn_method_moqclaim_set_auth( + Pointer ptr, + RustBuffer credential, + Pointer uniffiStatus, +); + @Native, Uint64, Pointer)>( assetId: _uniffiAssetId, ) @@ -22458,6 +22671,15 @@ external RustBuffer uniffi_moq_ffi_fn_method_moqroute_instance_id( Pointer uniffiStatus, ); +@Native, RustBuffer, Pointer)>( + assetId: _uniffiAssetId, +) +external void uniffi_moq_ffi_fn_method_moqroute_set_auth( + Pointer ptr, + RustBuffer credential, + Pointer uniffiStatus, +); + @Native, Uint64, Pointer)>( assetId: _uniffiAssetId, ) @@ -23172,6 +23394,15 @@ external Pointer uniffi_moq_ffi_fn_method_moqsession_consume( Pointer uniffiStatus, ); +@Native< + Pointer Function(Pointer, RustBuffer, Pointer) +>(assetId: _uniffiAssetId) +external Pointer uniffi_moq_ffi_fn_method_moqsession_credential( + Pointer ptr, + RustBuffer token, + Pointer uniffiStatus, +); + @Native Function(Pointer, Pointer)>( assetId: _uniffiAssetId, ) @@ -24201,6 +24432,12 @@ external int uniffi_moq_ffi_checksum_method_moqaudioproducer_reservation(); @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqaudioproducer_write(); +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqcredential_answered(); + +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqcredential_cancel(); + @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqgrantconsumer_cancel(); @@ -24540,6 +24777,9 @@ external int uniffi_moq_ffi_checksum_method_moqclaim_instance_id(); @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqclaim_requested(); +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqclaim_set_auth(); + @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqclaim_set_cost(); @@ -24582,6 +24822,9 @@ external int uniffi_moq_ffi_checksum_method_moqroute_cancel(); @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqroute_instance_id(); +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqroute_set_auth(); + @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqroute_set_cost(); @@ -24774,6 +25017,9 @@ external int uniffi_moq_ffi_checksum_method_moqsession_closed(); @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqsession_consume(); +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqsession_credential(); + @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqsession_grants(); @@ -25101,6 +25347,12 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_method_moqaudioproducer_write() != 48796) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } + if (uniffi_moq_ffi_checksum_method_moqcredential_answered() != 56846) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } + if (uniffi_moq_ffi_checksum_method_moqcredential_cancel() != 10608) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } if (uniffi_moq_ffi_checksum_method_moqgrantconsumer_cancel() != 22691) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } @@ -25456,6 +25708,9 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_method_moqclaim_requested() != 13700) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } + if (uniffi_moq_ffi_checksum_method_moqclaim_set_auth() != 38794) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } if (uniffi_moq_ffi_checksum_method_moqclaim_set_cost() != 15591) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } @@ -25498,6 +25753,9 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_method_moqroute_instance_id() != 47013) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } + if (uniffi_moq_ffi_checksum_method_moqroute_set_auth() != 43097) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } if (uniffi_moq_ffi_checksum_method_moqroute_set_cost() != 30911) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } @@ -25693,6 +25951,9 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_method_moqsession_consume() != 57909) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } + if (uniffi_moq_ffi_checksum_method_moqsession_credential() != 33369) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } if (uniffi_moq_ffi_checksum_method_moqsession_grants() != 48347) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } diff --git a/doc/lib/c/index.md b/doc/lib/c/index.md index 1402c5a7147..6a9ace3febc 100644 --- a/doc/lib/c/index.md +++ b/doc/lib/c/index.md @@ -81,7 +81,7 @@ if (moq_route_announce((uint32_t)route) < 0) ``` The route keeps the broadcast announced: `moq_route_free()` retracts it, and -`moq_publish_close()` ends both. A route carries only a cost and the stream's instance; +`moq_publish_close()` ends both. A route carries a cost, the stream's instance, and optionally a credential (`moq_route_set_auth()`); relays add the hops. `moq_origin_route()` mints a fresh instance (`moq_route_instance()`) and keeps it across `moq_route_withdraw()` and `moq_route_announce()`, so a restarted publisher, being a new route, is a new stream that `moq_origin_follow` hands viewers instead of a stalled @@ -93,7 +93,7 @@ together, so restarting the sequences under an old instance splices unrelated gr their tracks. To serve paths on demand, `moq_origin_claim()` returns a claim handle and delivers each request to answer with `moq_broadcast_request_serve()`, `moq_broadcast_request_route()`, or `moq_broadcast_request_reject()`; `moq_claim_announce()`, `moq_claim_withdraw()`, -`moq_claim_set_cost()`, and `moq_claim_free()` control the claim. Unless one is named, a claim moves to a fresh +`moq_claim_set_cost()`, `moq_claim_set_auth()`, and `moq_claim_free()` control the claim. Unless one is named, a claim moves to a fresh instance (`moq_claim_instance()`) each time a job holding its current one ends, and a job from `moq_broadcast_request_route()` takes the claim's, so on moq-lite 07 a reader resolved through the claim does not restart; older wires mint another instance for the job's route, restarting it. @@ -117,7 +117,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`, and that phase's own `data`: the negative `failure` of the latest attempt while offline or migrating, and the live `version` while connected or migrating. 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. Malformed credentials report `MOQ_PROTOCOL_KIND_MALFORMED_AUTH_TOKEN`; correct the token before reconnecting with it. 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. `moq_session_tokens_set()` replaces them before they expire: the next dial presents them, and a live AUTH session at once. A live session without AUTH (lite before 07, or moq-transport without the extension) keeps the old credential until it redials, and `moq_session_tokens_answered()` reports `MOQ_ERROR_UNSUPPORTED` for it. Otherwise that call reports the peer's verdict. Follow what the peer granted with `moq_session_grants()`. +- **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. `moq_session_tokens_set()` replaces them before they expire: the next dial presents them, and a live AUTH session at once. A live session without AUTH (lite before 07, or moq-transport without the extension) keeps the old credential until it redials, and `moq_session_tokens_answered()` reports `MOQ_ERROR_UNSUPPORTED` for it. Otherwise that call reports the peer's verdict. Follow what the peer granted with `moq_session_grants()`. For a per-request token, `moq_session_credential()` presents it on the live session, or the next one while offline, and returns a credential handle at once: name it with `moq_route_set_auth()` or `moq_claim_set_auth()`, and setting a new one renews the announcement in place, without redialing. Only the session that minted it carries it, a dialed one across its reconnects; another session announcing the same route uses its own scope. `moq_credential_answered()` reports the peer's verdict: moq-transport accepts at once, since only the request carries it, moq-lite needs AUTH, where the grant joins the session's, and is `MOQ_ERROR_UNSUPPORTED` without it. `moq_credential_free()` withdraws the token once no route or claim names it, answered or not, and ends a wait still pending. - **Threading.** Call any function from any thread. Callbacks run on moq-c's runtime thread; the callback and its `user_data` must be safe to use there. Synchronize access to data shared with other threads, and dispatch UI work to your UI 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`. - **Subscription defaults.** A zeroed `moq_subscription`, a NULL pointer, and `moq_subscription_defaults()` all mean moq-net's defaults, whose zero `delay` starts at the newest group and skips a superseded one at once. Set `delay.max_unlimited` for no subscriber limit, and `delay.min_unstated` to leave the target latency unstated. diff --git a/doc/lib/cpp/index.md b/doc/lib/cpp/index.md index 829a6e780c9..2c51cd02732 100644 --- a/doc/lib/cpp/index.md +++ b/doc/lib/cpp/index.md @@ -251,7 +251,7 @@ A producer publishes everything you write, subscribed or not, so a late subscrib On `moq-lite-07-wip` (opt in with `ClientConfig::versions`), `session->grants()` returns a `GrantConsumer`: `grant()` is the `Grant` the relay sent back, empty until it answers and while a client session reconnects, and `changed()` waits for the next one until `cancel()`. -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. `session->tokens()->set(...)` replaces them before they expire: the next dial presents them, a live AUTH session at once, and after an authorization refusal the session redials only if they changed. `answered()` resolves once the relay accepts them, or to an `Error` carrying its refusal code. A server-accepted session's `tokens()` is null. A `?jwt=` already in the URL stays the connection credential, which is how to reach a relay older than this release. +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. `session->tokens()->set(...)` replaces them before they expire: the next dial presents them, a live AUTH session at once, and after an authorization refusal the session redials only if they changed. `answered()` resolves once the relay accepts them, or to an `Error` carrying its refusal code. A server-accepted session's `tokens()` is null. A `?jwt=` already in the URL stays the connection credential, which is how to reach a relay older than this release. For a per-request token, `session->credential(token)` returns a `Credential` that `route->set_auth(credential)` or `claim->set_auth(credential)` names (empty for none); setting a new one renews the announcement in place. Only the session that minted it carries it, a client session across its reconnects; another session announcing the same route uses its own scope. On moq-transport `answered` resolves at once, since only the request carries the token; on moq-lite the token rides AUTH instead (07 only, unsupported before), so `answered` is the peer's verdict and the grant joins the whole session's, not only the named route's. `answered()` resolves to the verdict, and `cancel()` withdraws it unless a route or claim still names it. ## Stats diff --git a/doc/lib/dart/index.md b/doc/lib/dart/index.md index fda5cdcf01b..8779f6929bf 100644 --- a/doc/lib/dart/index.md +++ b/doc/lib/dart/index.md @@ -159,7 +159,7 @@ shows which module holds each. - **Stats.** `session.stats()` reports `rtt`, `estimatedSendRateBps`, and the byte and packet counters (`bytesSent`, `bytesReceived`, `bytesLost`, `packetsSent`, `packetsReceived`, `packetsLost`), and `datagramsUnrouted`, the datagrams received for a subscription with no datagram track, reported only on moq-lite 05 and later, which carry datagrams. 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 reconnects. A first dial that reaches no session ends the session with that error rather than retrying. 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. - **Viewers.** `client.viewers()` divides the connection's receive estimate among the broadcasts you watch, max-min fair by each one's ceiling. `watch(maxBps: ...)` returns a `Viewer` claiming up to what the largest rendition the player would switch to needs; `rate()` reads its slice in bits per second, `null` while there is no estimate, so hold the current selection. `update(maxBps: ...)` changes the ceiling, such as when the catalog enables or disables a rendition. Holding a viewer requests PROBE, and the session sends the viewers' summed ceilings as the PROBE target; `dispose()` it once the broadcast stops being watched. `setTarget(targetBps: ...)` sends another target, `0` sends none, and `null` follows the viewers again. Ceilings or a target past 2^53 - 1 bps, the most a PROBE target carries, throw `OutOfRangeMoqException`. Client viewers survive reconnects and read `null` while disconnected. - **Authorization.** On `moq-lite-07-wip` (opt in with `ConnectOptions(versions: ...)`), listen to `moq.grants().updates()` for the `Grant` the relay sent back and each change, `null` until it answers and while a client session reconnects. -- **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 session redials only if they changed. `await tokens().answered()` returns once the relay accepts them and throws its refusal with the relay's code. A `?jwt=` already in the URL stays the connection credential, which is how to reach a relay older than this release. +- **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 session redials only if they changed. `await tokens().answered()` returns once the relay accepts them and throws its refusal with the relay's code. A `?jwt=` already in the URL stays the connection credential, which is how to reach a relay older than this release. For a per-request token, `moq.credential(token)` returns a `Credential` that `route.setAuth(credential: credential)` or `claim.setAuth(credential: credential)` names (`null` for none); setting a new one renews the announcement in place. Only the session that minted it carries it, a client session across its reconnects; another session announcing the same route uses its own scope. On moq-transport `answered` resolves at once, since only the request carries the token; on moq-lite the token rides AUTH instead (07 only, unsupported before), so `answered` is the peer's verdict and the grant joins the whole session's, not only the named route's. `await credential.answered()` throws the peer's refusal, and `credential.cancel()` withdraws it unless a route or claim still names it. - **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. Audio and video subscribe setup returns `UnsupportedConfigMoqException` when the decoder refuses the codec, layout, or description; a player can then try its next ranked rendition. Decoder faults and later peer or decode failures do not report that setup refusal. - **Subscription defaults.** A bare `Subscription()`, passing no subscription, and `defaultSubscription()` all mean moq-net's defaults, whose zero `delay` starts at the newest group and skips a superseded one at once. `Delay` defaults both bounds to zero: pass `max: null` for no subscriber limit, or `min: null` to leave the target latency unstated. Decoders take the same `Delay`. diff --git a/doc/lib/go/index.md b/doc/lib/go/index.md index c92e86e711a..50b14bd9d20 100644 --- a/doc/lib/go/index.md +++ b/doc/lib/go/index.md @@ -136,7 +136,7 @@ shows which module holds each. - **Stats.** `Session().Stats()` reports `RTT`, `EstimatedSendRateBps`, and the byte and packet counters (`BytesSent`, `BytesReceived`, `BytesLost`, `PacketsSent`, `PacketsReceived`, `PacketsLost`), and `DatagramsUnrouted`, the datagrams received for a subscription with no datagram track, reported only on moq-lite 05 and later, which carry datagrams. 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 reconnects. A first dial that reaches no session ends the session with that error rather than retrying. 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 `Cancel()` 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. - **Viewers.** `Session().Viewers()` divides the connection's receive estimate among the broadcasts you watch, max-min fair by each one's ceiling. `Watch(maxBps)` returns a `Viewer` claiming up to what the largest rendition the player would switch to needs; `Rate()` reads its slice in bits per second, nil while there is no estimate, so hold the current selection. `Update(maxBps)` changes the ceiling, such as when the catalog enables or disables a rendition. Holding a viewer requests PROBE, and the session sends the viewers' summed ceilings as the PROBE target; `Close()` it once the broadcast stops being watched, from the goroutine that last used it. `SetTarget(&bps)` sends another target, zero sends none, and nil follows the viewers again. `Watch`, `Update`, and `SetTarget` return `moq.ErrOutOfRange` when the ceilings or the target would pass 2^53 - 1 bps, the most a PROBE target carries. Client viewers survive reconnects and read nil while disconnected. - **Authorization.** On `moq-lite-07-wip` (opt in with `ClientConfig.Versions`), `Session().Grants()` returns a `GrantConsumer`: `Grant()` is the `Grant` the relay sent back, nil until it answers and while a client session reconnects, and `Changed(ctx)` waits for the next one. `Cancel()` it when done. -- **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. `Session().Tokens().Set(...)` replaces a client session's tokens before they expire: the next dial presents them, a live AUTH session at once, and after an authorization refusal the session redials only if they changed. `Answered(ctx)` returns nil once the relay accepts them, or its refusal with the relay's code. A `?jwt=` already in the URL stays the connection credential, which is how to reach a relay older than this release. +- **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. `Session().Tokens().Set(...)` replaces a client session's tokens before they expire: the next dial presents them, a live AUTH session at once, and after an authorization refusal the session redials only if they changed. `Answered(ctx)` returns nil once the relay accepts them, or its refusal with the relay's code. A `?jwt=` already in the URL stays the connection credential, which is how to reach a relay older than this release. For a per-request token, `Session().Credential(token)` returns a `Credential` that `Route.SetAuth` or `Claim.SetAuth` names (nil for none); setting a new one renews the announcement in place. Only the session that minted it carries it, a client session across its reconnects; another session announcing the same route uses its own scope. On moq-transport `Answered` resolves at once, since only the request carries the token; on moq-lite the token rides AUTH instead (07 only, unsupported before), so `Answered` is the peer's verdict and the grant joins the whole session's, not only the named route's. `Answered(ctx)` returns the verdict, and `Cancel()` withdraws it unless a route or claim still names it. - **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. Audio and video subscribe setup returns `moq.ErrUnsupportedConfig` when the decoder refuses the codec, layout, or description; a player can then try its next ranked rendition. Decoder faults and later peer or decode failures do not report that setup refusal. - **Subscription defaults.** A zero `moq.Subscription{}`, a nil `*Subscription`, and `moq.DefaultSubscription()` all mean moq-net's defaults, whose zero `Delay` starts at the newest group and skips a superseded one at once. Set `Delay.MaxUnlimited` for no subscriber limit, and `Delay.MinUnstated` to leave the target latency unstated. Decoders take the same `moq.Delay`. diff --git a/doc/lib/js/net.md b/doc/lib/js/net.md index dadddf1f5ef..3d5595cadc9 100644 --- a/doc/lib/js/net.md +++ b/doc/lib/js/net.md @@ -59,7 +59,7 @@ route.announce(); - **Subscription defaults.** `Track.Subscription.default()` returns the default subscription, whose zero `delay.max` skips a superseded group at once; calling `subscribe()` with no options means the same. An omitted `delay` field means the same zero; `max: null` sets no subscriber limit. - **A subscription asks for `ranges`.** Each [range](/concept/moq-lite#subscriptions) starts and ends at an `absolute` group, one `relative` to the latest, or the group containing a `time` (a `Timestamp`), and a reader reads only its own ranges. A read whose ranges all end finishes once every group in them is handed out, dropped, or skipped, as in Rust. A time anchor needs a timed track and `moq-lite-07-wip` over a session. Passing the old `groups` key throws a `TypeError` naming `ranges`; `withRange` only narrows reads locally. - **Hidden paths** stay out of discovery unless the announce request opts in. See [hidden broadcasts](/concept/moq-lite#hidden-broadcasts). -- **Authorization is in band.** On moq-lite 07 (opt in with `versions: ["moq-lite-07-wip", "moq-lite-06"]`, listing every fallback you still want), `auth.grant` watches what the relay lets this side publish and subscribe to, and `auth.add(token)` presents another token without reconnecting. Publishing outside the grant closes the connection with `SessionCode.Unauthorized`, naming the path. On moq-lite, a subscription or track listing the grant stops covering resets with `StreamCode.Unauthorized` and the session stays up. On moq-transport, `auth.add(token)` only keeps the token for the requests that name it: a route created with the `Auth.Credential` it returns (`origin.route(path, { auth })`) carries it on that announcement's `PUBLISH_NAMESPACE`; `route.setAuth(other)` renews the request. A `Connection`'s `auth` handle (a getter on a pooled connection, following its URL) outlives its sessions: a token added there is presented on every reconnect and keeps riding the announcements that name it, with no re-add or re-announce. A one-shot session's `auth` ends with it, unless `Connection.connect` was given a `new Auth.Handle()` as `auth`. +- **Authorization is in band.** On moq-lite 07 (opt in with `versions: ["moq-lite-07-wip", "moq-lite-06"]`, listing every fallback you still want), `auth.grant` watches what the relay lets this side publish and subscribe to, and `auth.add(token)` presents another token without reconnecting, returning its `Auth.Credential` at once; `await credential.answered` waits for the peer's AUTH verdict on moq-lite 07, resolves at once on moq-transport, and rejects with `Unsupported` on older moq-lite. Publishing outside the grant closes the connection with `SessionCode.Unauthorized`, naming the path. On moq-lite, a subscription or track listing the grant stops covering resets with `StreamCode.Unauthorized` and the session stays up. On moq-transport, `auth.add(token)` only keeps the token for the requests that name it: a route created with the `Auth.Credential` it returns (`origin.route(path, { auth })`) carries it on that announcement's `PUBLISH_NAMESPACE`; `route.setAuth(other)` renews the request. A `Connection`'s `auth` handle (a getter on a pooled connection, following its URL) outlives its sessions: a token added there is presented on every reconnect and keeps riding the announcements that name it, with no re-add or re-announce. A one-shot session's `auth` ends with it, unless `Connection.connect` was given a `new Auth.Handle()` as `auth`. - **A graceful close waits.** `await connection.close()` withdraws announcements and drains for up to one second. `moq-lite-05` and newer wait for the subscriber to receive finished tails; older Lite versions wait only for transport ACKs. IETF media streams are not drained. `abort()` ends immediately. Examples: diff --git a/doc/lib/kt/index.md b/doc/lib/kt/index.md index 09a461c8ba8..1e7440cd31e 100644 --- a/doc/lib/kt/index.md +++ b/doc/lib/kt/index.md @@ -113,7 +113,7 @@ shows which module holds each. - **Stats.** `session.stats()` reports `rtt`, `estimatedSendRateBps`, and the byte and packet counters (`bytesSent`, `bytesReceived`, `bytesLost`, `packetsSent`, `packetsReceived`, `packetsLost`), and `datagramsUnrouted`, the datagrams received for a subscription with no datagram track, reported only on moq-lite 05 and later, which carry datagrams. 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 reconnects. A first dial that reaches no session ends the session with that error rather than retrying. 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. - **Viewers.** `moq.viewers()` divides the connection's receive estimate among the broadcasts you watch, max-min fair by each one's ceiling. `watch(maxBps)` returns a `Viewer` claiming up to what the largest rendition the player would switch to needs; `rate()` reads its slice in bits per second, `null` while there is no estimate, so hold the current selection. `update(maxBps)` changes the ceiling, such as when the catalog enables or disables a rendition. Holding a viewer requests PROBE, and the session sends the viewers' summed ceilings as the PROBE target; `close()` it, or leave `use { }`, once the broadcast stops being watched. `setTarget(bps)` sends another target, `0` sends none, and `null` follows the viewers again. Ceilings or a target past 2^53 - 1 bps, the most a PROBE target carries, throw `MoqException.OutOfRange`. Client viewers survive reconnects and read `null` while disconnected. - **Authorization.** On `moq-lite-07-wip` (opt in with `ClientConfig(versions = ...)`), collect `moq.grants().updates()` for the `Grant` the relay sent back and each change, `null` until it answers and while a client session reconnects. Ending the collection cancels the consumer. -- **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 session redials only if they changed. Suspending `tokens().answered()` returns once the relay accepts them and throws its refusal with the relay's code. A `?jwt=` already in the URL stays the connection credential, which is how to reach a relay older than this release. +- **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 session redials only if they changed. Suspending `tokens().answered()` returns once the relay accepts them and throws its refusal with the relay's code. A `?jwt=` already in the URL stays the connection credential, which is how to reach a relay older than this release. For a per-request token, `moq.credential(token)` returns a `Credential` that `route.setAuth(credential)` or `claim.setAuth(credential)` names (`null` for none); setting a new one renews the announcement in place. Only the session that minted it carries it, a client session across its reconnects; another session announcing the same route uses its own scope. On moq-transport `answered` resolves at once, since only the request carries the token; on moq-lite the token rides AUTH instead (07 only, unsupported before), so `answered` is the peer's verdict and the grant joins the whole session's, not only the named route's. Suspending `credential.answered()` throws the peer's refusal, and `credential.cancel()` withdraws it unless a route or claim still names it. - **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. Audio and video subscribe setup returns `MoqException.UnsupportedConfig` when the decoder refuses the codec, layout, or description; a player can then try its next ranked rendition. Decoder faults and later peer or decode failures do not report that setup refusal. - **Subscription defaults.** `Subscription.default()` returns moq-net's defaults, whose zero `delay` starts at the newest group and skips a superseded one at once; passing no subscription means the same. `copy` it to change a field; building a `Subscription(...)` must name its `Delay(min, max)`, where a null `max` sets no subscriber limit and a null `min` leaves the target latency unstated. Decoders take the same `Delay`. diff --git a/doc/lib/py/index.md b/doc/lib/py/index.md index 2f4d4588a1e..9785aa23523 100644 --- a/doc/lib/py/index.md +++ b/doc/lib/py/index.md @@ -128,7 +128,7 @@ shows which module holds each. - **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`), and `datagrams_unrouted`, the datagrams received for a subscription with no datagram track, reported only on moq-lite 05 and later, which carry datagrams. 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 reconnects. A first dial that reaches no session ends the session with that error rather than retrying. Each watcher keeps its own revision. Hold the owned `BandwidthConsumer` 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. - **Viewers.** `session.viewers()` divides the receive estimate among the broadcasts you watch. `watch(max_bps)` returns a `Viewer` claiming up to a ceiling, the bitrate of the largest rendition you would switch to; its `rate()` reads that viewer's fair share in bits per second, `None` while there is no estimate (keep the current rendition), and `update(max_bps)` changes the ceiling. Holding a viewer requests the estimate like `recv_bandwidth()`, and dropping it releases the claim. The session sends the summed ceilings as its PROBE target; `set_target(bps)` overrides that, `0` sends none, and `None` follows the viewers again. Ceilings or a target past 2^53 - 1 bps, the most a PROBE target carries, raise `moq.Error.OutOfRange`. - **Authorization.** On `moq-lite-07-wip` (opt in with `versions=`), `async for grant in session.grants()` yields the `Grant` the relay sent back and each change, `None` until it answers and while a client session reconnects. `grants().grant()` reads it once; cancelling or leaving an `async with` releases the wait. -- **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. `session.tokens().set([...])` replaces a client session's tokens before they expire: the next dial presents them, a live AUTH session at once, and after an authorization refusal the session redials only if they changed. `await tokens.answered()` returns once the relay accepts them and raises its refusal with the relay's code. A `?jwt=` already in the URL stays the connection credential, which is how to reach a relay older than this release. +- **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. `session.tokens().set([...])` replaces a client session's tokens before they expire: the next dial presents them, a live AUTH session at once, and after an authorization refusal the session redials only if they changed. `await tokens.answered()` returns once the relay accepts them and raises its refusal with the relay's code. A `?jwt=` already in the URL stays the connection credential, which is how to reach a relay older than this release. For a per-request token, `session.credential(token)` returns a `Credential` that `route.set_auth(credential)` or `claim.set_auth(credential)` names; setting a new one renews the announcement in place. Only the session that minted it carries it, a client session across its reconnects; another session announcing the same route uses its own scope. On moq-transport `answered` resolves at once, since only the request carries the token; on moq-lite the token rides AUTH instead (07 only, unsupported before), so `answered` is the peer's verdict and the grant joins the whole session's, not only the named route's. `await credential.answered()` raises the peer's refusal, and `credential.cancel()` withdraws it unless a route or claim still names it. - **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. Audio and video subscribe setup returns `moq.Error.UnsupportedConfig` when the decoder refuses the codec, layout, or description; a player can then try its next ranked rendition. Decoder faults and later peer or decode failures do not report that setup refusal. - **Subscription defaults.** A bare `moq.Subscription()`, passing no subscription, and `moq.Subscription.default()` all mean moq-net's defaults, whose zero `delay` starts at the newest group and skips a superseded one at once. Pass `delay=moq.Delay(max=None)` for no subscriber limit, or `min=None` to leave the target latency unstated. Decoders take the same `moq.Delay`. diff --git a/doc/lib/rs/moq-net.md b/doc/lib/rs/moq-net.md index 42543e34f91..b76057167b6 100644 --- a/doc/lib/rs/moq-net.md +++ b/doc/lib/rs/moq-net.md @@ -31,7 +31,7 @@ What you use it for, beyond what the concept page already describes: - **You drive the session, or `moq-tokio` does.** `connect` and `accept` return a session plus a driver that never reads the clock itself. `moq_net::time::run` polls it on tokio. `moq-tokio` hides that. A custom transport implements `moq_net::transport::poll`, and `moq_net::transport::memory::pair` links two sessions in one process with no sockets, for tests or in-process links. `Server` bounds each accept with one deadline (10 s by default, `with_timeout`) across SETUP, your admission decision (`Handshake::expired`), and moq-lite 01/02's SETUP reply, and opens a receive window the transport held back once you admit (`with_receive_window`). - **Reconnect policy comes with it.** With the `async` feature, `moq_net::Reconnect` decides which of a peer's addresses to dial, how long each dial and handshake may take, when to back off and give up, and how to follow a GOAWAY, without doing I/O: you dial each `Attempt` it asks for and report back. `moq-tokio`'s `Connection` runs it, so a client on another runtime gets the same policy rather than a copy. The first walk of the peer's addresses fails fast: if it yields no session, the policy ends with that walk's error instead of retrying, so a setup mistake such as an untrusted certificate or no version in common surfaces at once, and a client started before its relay is reachable exits for its supervisor to restart. Once a session has been established, later failures back off and retry, so relay restarts still recover. - **A session bounds what its peer can hold.** Each request holds a stream, so the transport's stream credit bounds subscriptions and FETCHes. Once that credit runs out, a waiting open resets the lowest-priority group stream below it, which the subscriber sees dropped, so the credit it frees goes to the higher-priority group. `session::Limits` (via `Client::with_limits` and `Server::with_limits`) bounds the announcements per session. The default suits a relay mesh, so lower it for untrusted peers. Past it, the session closes with `TOO_MANY_REQUESTS`. Peer-declared lengths are capped before they are buffered. -- **Authorization is in band.** On moq-lite 07 (`moq-lite-07-wip`, opt-in), `session.auth()` presents tokens without reconnecting and watches the grant the peer sent back. `add(token)` resolves with the token once the peer takes it: on its AUTH stream, or on moq-transport without AUTH, where only a request that names it carries it. A handle passed to `Client::with_auth` outlives each session, so its tokens, and the routes naming them, carry across your own reconnects. A server verifies tokens itself by taking `handshake.requests()` before `ok()`. `authorize(&grant)` narrows or widens a live session on any version: what falls outside resets with `Unauthorized` and the session stays up. A client that publishes outside its grant closes the session, naming the path. +- **Authorization is in band.** On moq-lite 07 (`moq-lite-07-wip`, opt-in), `session.auth()` presents tokens without reconnecting and watches the grant the peer sent back. `add(token)` presents another and returns its `Credential` at once, and `credential.answered().await` waits for the peer to take it: on its AUTH stream, or at once on moq-transport without AUTH, where only a request that names it carries it. Dropping its last clone, including any a route holds, withdraws it, answered or not. A handle passed to `Client::with_auth` outlives each session, so its tokens, and the routes naming them, carry across your own reconnects. A server verifies tokens itself by taking `handshake.requests()` before `ok()`. `authorize(&grant)` narrows or widens a live session on any version: what falls outside resets with `Unauthorized` and the session stays up. A client that publishes outside its grant closes the session, naming the path. - **A graceful close waits.** `session.close().await` withdraws announcements and drains for up to one second. `moq-lite-05` and newer wait for the subscriber to receive finished tails; older Lite versions and IETF wait only for transport ACKs. `abort` ends immediately. ```bash diff --git a/doc/lib/swift/index.md b/doc/lib/swift/index.md index ee7006b3b62..e1cf59e4ba7 100644 --- a/doc/lib/swift/index.md +++ b/doc/lib/swift/index.md @@ -110,7 +110,7 @@ shows which module holds each. - **Stats.** `session.stats()` reports `rtt`, `estimatedSendRateBps`, and the byte and packet counters (`bytesSent`, `bytesReceived`, `bytesLost`, `packetsSent`, `packetsReceived`, `packetsLost`), and `datagramsUnrouted`, the datagrams received for a subscription with no datagram track, reported only on moq-lite 05 and later, which carry datagrams. 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 reconnects. A first dial that reaches no session ends the session with that error rather than retrying. 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. - **Viewers.** `session.viewers()` divides the receive estimate among the broadcasts you watch. `watch(maxBps:)` returns a `Viewer` claiming up to a ceiling, the bitrate of the largest rendition you would switch to; its `rate()` reads that viewer's fair share in bits per second, `nil` while there is no estimate (keep the current rendition), and `update(maxBps:)` changes the ceiling. Holding a viewer requests the estimate like `recvBandwidth()`, and releasing it releases the claim. The session sends the summed ceilings as its PROBE target; `setTarget(targetBps:)` overrides that, `0` sends none, and `nil` follows the viewers again. `watch`, `update`, and `setTarget` throw `MoqError.OutOfRange` when the ceilings or the target would pass 2^53 - 1 bps, the most a PROBE target carries. - **Authorization.** On `moq-lite-07-wip` (opt in with `Client(versions:)`), `for try await grant in session.grants()` yields the `Grant` the relay sent back and each change, `nil` until it answers and while a client session reconnects. Cancelling the iterating task cancels the native wait. -- **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 session.tokens()?.set([...])` replaces them before they expire: the next dial presents them, a live AUTH session at once, and after an authorization refusal the session redials only if they changed. `try await tokens.answered()` returns once the relay accepts them and throws its refusal with the relay's code. A `?jwt=` already in the URL stays the connection credential, which is how to reach a relay older than this release. +- **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 session.tokens()?.set([...])` replaces them before they expire: the next dial presents them, a live AUTH session at once, and after an authorization refusal the session redials only if they changed. `try await tokens.answered()` returns once the relay accepts them and throws its refusal with the relay's code. A `?jwt=` already in the URL stays the connection credential, which is how to reach a relay older than this release. For a per-request token, `try session.credential(token)` returns a `Credential` that `try route.setAuth(credential)` or `try claim.setAuth(credential)` names (`nil` for none); setting a new one renews the announcement in place. Only the session that minted it carries it, a client session across its reconnects; another session announcing the same route uses its own scope. On moq-transport `answered` resolves at once, since only the request carries the token; on moq-lite the token rides AUTH instead (07 only, unsupported before), so `answered` is the peer's verdict and the grant joins the whole session's, not only the named route's. `try await credential.answered()` throws the peer's refusal, and `credential.cancel()` withdraws it unless a route or claim still names it. - **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. Audio and video subscribe setup returns `MoqError.UnsupportedConfig` when the decoder refuses the codec, layout, or description; a player can then try its next ranked rendition. Decoder faults and later peer or decode failures do not report that setup refusal. - **Subscription defaults.** `Subscription.default` holds moq-net's defaults, whose zero `delay` starts at the newest group and skips a superseded one at once; passing no subscription means the same. Copy it to change a field; building a `Subscription(...)` must name its `Delay(min:max:)`, where a `nil` `max` sets no subscriber limit and a `nil` `min` leaves the target latency unstated. Decoders take the same `Delay`. diff --git a/go/wrapper/auth_test.go b/go/wrapper/auth_test.go index 0dbefc105c6..721ba406a46 100644 --- a/go/wrapper/auth_test.go +++ b/go/wrapper/auth_test.go @@ -102,3 +102,87 @@ func TestSessionAuthReadsTheGrant(t *testing.T) { t.Fatalf("grant after a refusal = %+v", got) } } + +// A credential is answered by the peer, names a route and a claim, and cancels. +func TestSessionCredentialLifecycle(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + const version = "moq-lite-07-wip" + server, err := moq.Listen(ctx, "127.0.0.1:0", moq.WithTLSGenerate("localhost"), moq.WithServerVersions(version)) + if err != nil { + t.Fatal(err) + } + defer server.Close() + + accepted := make(chan *moq.Session, 1) + go func() { + req, err := server.Accept(ctx) + if err != nil || req == nil { + return + } + session, err := req.Accept(ctx, nil, nil) + if err != nil { + return + } + accepted <- session + }() + + client, err := moq.Dial(ctx, "https://"+server.LocalAddr(), moq.ClientConfig{ + TLS: moq.ClientTLS{Insecure: true}, + Versions: []string{version}, + Once: true, + }) + if err != nil { + t.Fatal(err) + } + defer client.Close() + select { + case session := <-accepted: + defer session.Cancel(0) + case <-ctx.Done(): + t.Fatal("no session accepted") + } + + session := client.Session() + if _, err := session.Credential(""); !errors.Is(err, moq.ErrConfig) { + t.Fatalf("empty token = %v, want ErrConfig", err) + } + credential, err := session.Credential("extra") + if err != nil { + t.Fatal(err) + } + // The server verifies no tokens of its own, so it refuses this one. + if err := credential.Answered(ctx); !errors.Is(err, moq.ErrUnsupported) { + t.Fatalf("answered = %v, want ErrUnsupported", err) + } + + route, err := session.Publish().Route("room/x", moq.RouteOptions{Hidden: true}) + if err != nil { + t.Fatal(err) + } + defer route.Cancel() + claim, err := session.Publish().Claim("live", moq.RouteOptions{Hidden: true}) + if err != nil { + t.Fatal(err) + } + defer claim.Cancel() + for _, set := range []func(*moq.Credential) error{route.SetAuth, claim.SetAuth} { + if err := set(credential); err != nil { + t.Fatal(err) + } + if err := set(nil); err != nil { + t.Fatal(err) + } + } + + credential.Cancel() + if err := credential.Answered(ctx); !errors.Is(err, moq.ErrCancelled) { + t.Fatalf("answered after cancel = %v, want ErrCancelled", err) + } + for _, set := range []func(*moq.Credential) error{route.SetAuth, claim.SetAuth} { + if err := set(credential); !errors.Is(err, moq.ErrCancelled) { + t.Fatalf("set_auth with a cancelled credential = %v, want ErrCancelled", err) + } + } +} diff --git a/go/wrapper/origin.go b/go/wrapper/origin.go index d49f155e47e..a70b7c559a4 100644 --- a/go/wrapper/origin.go +++ b/go/wrapper/origin.go @@ -147,6 +147,15 @@ func (r *Route) SetCost(cost uint64) error { return r.inner.SetCost(cost) } +// SetAuth swaps the credential the route's announcing request carries, +// renewing that request in place, or nil to announce under the session's +// scope. Only the session it was minted on carries it. Errors with ErrClosed +// once its broadcast closed, keeping the credential it had, and with +// ErrCancelled for a cancelled credential. +func (r *Route) SetAuth(credential *Credential) error { + return r.inner.SetAuth(credential.ffi()) +} + // Cancel retracts the route now, rather than when the collector reclaims it. // Later calls fail with ErrCancelled. Its broadcast lives on until // [BroadcastProducer.Close]. @@ -188,6 +197,14 @@ func (c *Claim) SetCost(cost uint64) error { return c.inner.SetCost(cost) } +// SetAuth swaps the credential the claim's announcing request carries, +// renewing that request in place, or nil to announce under the session's +// scope. Only the session it was minted on carries it. Errors with +// ErrCancelled for a cancelled credential. +func (c *Claim) SetAuth(credential *Credential) error { + return c.inner.SetAuth(credential.ffi()) +} + // Requested blocks until a consumer requests a path beneath this claim, // returning (nil, nil) once the claim is retracted or the origin closes. // diff --git a/go/wrapper/session.go b/go/wrapper/session.go index ce4dbc1ed94..fd7a933e74e 100644 --- a/go/wrapper/session.go +++ b/go/wrapper/session.go @@ -160,6 +160,48 @@ func (g *GrantConsumer) Changed(ctx context.Context) (*Grant, error) { // Cancel ends every current and future Changed call. func (g *GrantConsumer) Cancel() { g.inner.Cancel() } +// Credential presents a request token on this session, for a [Route] or [Claim] +// to carry with SetAuth. On moq-transport it rides each naming route's +// announcing request; on moq-lite it needs AUTH, where it earns a grant. A +// client session presents it again on every reconnect, and with no session live +// it waits for the next one. An empty token errors with ErrConfig. +func (s *Session) Credential(token string) (*Credential, error) { + inner, err := s.inner.Credential(token) + if err != nil { + return nil, err + } + return &Credential{inner: inner}, nil +} + +// Credential is a request token a session presents. It stays presented until +// Cancel, or until the collector reclaims it and no route or claim names it, +// answered or not. +type Credential struct { + inner *ffi.MoqCredential +} + +// Answered blocks until the peer's first verdict on the token: nil once a peer +// accepts it, at once on moq-transport, where only the requests that name it +// carry the token. It returns the peer's refusal as a ProtocolError carrying +// its code, an error for which errors.Is(err, ErrUnsupported) holds when +// nothing can carry a token (moq-lite without AUTH), and ErrCancelled after +// Cancel. +func (c *Credential) Answered(ctx context.Context) error { + return bridge.CallErr(ctx, nil, c.inner.Answered) +} + +// Cancel releases the credential now: it is withdrawn at once unless a route or +// claim still names it, and every Answered call ends with ErrCancelled. +func (c *Credential) Cancel() { c.inner.Cancel() } + +// ffi is the native credential a route or claim names, or none for nil. +func (c *Credential) ffi() **ffi.MoqCredential { + if c == nil { + return nil + } + return &c.inner +} + // Tokens returns the credentials a native client session presents, to replace // while it runs, or nil for a server-accepted session, which presents none. func (s *Session) Tokens() *Tokens { diff --git a/js/net/api/index.api.md b/js/net/api/index.api.md index a7302acad65..e15238f0213 100644 --- a/js/net/api/index.api.md +++ b/js/net/api/index.api.md @@ -25,6 +25,7 @@ export namespace Announce { export namespace Auth { export interface Credential { + readonly answered: PromiseLike; close(): void; readonly closed: PromiseLike; readonly grant: Signals.Getter; @@ -40,7 +41,7 @@ export namespace Auth { export class Handle { constructor(); abort(err?: Error): void; - add(token: string | Uint8Array): Promise; + add(token: string | Uint8Array): Auth.Credential; get grant(): Signals.Getter; } export class Unsupported extends Error { diff --git a/js/net/src/auth.test.ts b/js/net/src/auth.test.ts index 3c5d969fcab..54117af06ad 100644 --- a/js/net/src/auth.test.ts +++ b/js/net/src/auth.test.ts @@ -1,6 +1,15 @@ import { describe, expect, spyOn, test } from "bun:test"; import { Duration, Manual, yieldNow } from "@moq/clock"; -import { Duplicate, type Grant, Handle, type Issued, type Request, type Requests, Unsupported } from "./auth.ts"; +import { + type Credential, + Duplicate, + type Grant, + Handle, + type Issued, + type Request, + type Requests, + Unsupported, +} from "./auth.ts"; import { accept as acceptSession, connect as connectSession, type Session } from "./connection/index.ts"; import { SessionCode, SessionError, StreamCode, StreamError, toStreamCode } from "./error.ts"; import * as Ietf from "./ietf/index.ts"; @@ -15,6 +24,12 @@ import { abandoned } from "./util/demand.ts"; import { withTimeout } from "./util/timeout.ts"; import { wireOf } from "./wire.ts"; +/** Wait for a token's first answer, returning it as `add` returned it. */ +async function answered(token: T): Promise { + await token.answered; + return token; +} + const url = new URL("https://localhost:4443/test"); /** The next route event, skipping the live marker. */ @@ -86,7 +101,7 @@ describe.each([Lite.ALPN_07_WIP])("%s", (protocol) => { test("a shared handle presents its token on every session", async () => { const handle = new Handle(); // Added before any session: it waits for the first. - const adding = handle.add("token"); + const adding = answered(handle.add("token")); const first = await connect({ publish: new OriginProducer(), protocol, auth: handle }); answer(first.server.auth.requests(), (token) => (token ? grant(["first"], []) : grant([], []))); @@ -140,7 +155,7 @@ describe.each([Lite.ALPN_07_WIP])("%s", (protocol) => { const { client, server } = await connect({ publish: new OriginProducer(), protocol, auth: handle }); answer(server.auth.requests(), (token) => (token ? grant(["a"], []) : grant([], []))); try { - const token = await handle.add("token"); + const token = await answered(handle.add("token")); await waitFor(token.grant, (g) => g?.publish.equals(patterns("a")) === true); let ended = false; void token.closed.then(() => { @@ -184,7 +199,7 @@ describe.each([Lite.ALPN_07_WIP])("%s", (protocol) => { const { client, server } = await connect({ publish: new OriginProducer(), protocol, auth: handle }); answer(server.auth.requests(), (token) => (token ? grant(["a"], []) : grant([], []))); try { - const token = await handle.add("token"); + const token = await answered(handle.add("token")); await waitFor(token.grant, (g) => g?.publish.equals(patterns("a")) === true); const first = streams[0]; if (!first) throw new Error("the token was never presented"); @@ -227,7 +242,7 @@ describe.each([Lite.ALPN_07_WIP])("%s", (protocol) => { // Ending the handle ends the tokens it holds, and no session can be built on it. handle.abort(new Error("done")); - await expect(handle.add("late")).rejects.toThrow("done"); + expect(() => answered(handle.add("late"))).toThrow("done"); second.client.abort(); second.server.abort(); }); @@ -252,7 +267,7 @@ describe.each([Lite.ALPN_07_WIP])("%s", (protocol) => { test("a token without an acceptor reports unsupported", async () => { const { client, server } = await connect({ publish: new OriginProducer(), protocol }); await waitFor(client.auth.grant, (g) => g !== undefined); - await expect(client.auth.add("token")).rejects.toBeInstanceOf(Unsupported); + await expect(answered(client.auth.add("token"))).rejects.toBeInstanceOf(Unsupported); client.abort(); server.abort(); }); @@ -270,7 +285,7 @@ describe.each([Lite.ALPN_07_WIP])("%s", (protocol) => { })(); try { await waitFor(client.auth.grant, (g) => g?.publish.equals(patterns("a")) === true); - const token = await client.auth.add("update"); + const token = await answered(client.auth.add("update")); await waitFor(client.auth.grant, (g) => g?.publish.equals(patterns("a", "b")) === true); const publish = new Path.Patterns( Array.from({ length: 20 }, (_, i) => Path.Pattern.subtree(`${i}${"x".repeat(4000)}`)), @@ -317,7 +332,7 @@ describe.each([Lite.ALPN_07_WIP])("%s", (protocol) => { if (!setup) throw new Error("missing setup token"); const initial = setup.accept(grant(["baz"], [])); await waitFor(client.auth.grant, (g) => g !== undefined); - const adding = client.auth.add("new"); + const adding = answered(client.auth.add("new")); try { const unanswered = await requests.next(); if (!unanswered) throw new Error("missing token"); @@ -341,6 +356,35 @@ describe.each([Lite.ALPN_07_WIP])("%s", (protocol) => { } }); + test("closing a token before its answer rejects it and withdraws it", async () => { + // With no session live, closing withdraws it before any session presents it. + const handle = new Handle(); + const offline = handle.add("offline"); + offline.close(); + await expect(answered(offline)).rejects.toThrow(); + expect(await offline.closed).toBeNull(); + + // On a live session, closing withdraws it while the peer still holds it unanswered. + const { client, server } = await connect({ protocol }); + const requests = server.auth.requests(); + const setup = await requests.next(); + if (!setup) throw new Error("missing setup token"); + const initial = setup.accept(grant([], [])); + try { + const waiting = client.auth.add("waiting"); + const unanswered = await requests.next(); + if (!unanswered) throw new Error("missing token"); + waiting.close(); + await expect(answered(waiting)).rejects.toThrow(); + expect(await unanswered.closed).not.toBeUndefined(); + expect(waiting.grant.peek()).toBeUndefined(); + } finally { + initial.close(); + client.abort(); + server.abort(); + } + }); + test("a revoked grant withdraws its broadcasts without closing the session", async () => { using clock = manual(); const origin = new OriginProducer(); @@ -371,7 +415,7 @@ describe.each([Lite.ALPN_07_WIP])("%s", (protocol) => { // The union is empty but still a grant, and a new token restores it. const empty = await waitFor(client.auth.grant, (g) => g !== undefined && g.publish.size === 0); expect(empty?.subscribe.size).toBe(0); - const token = await client.auth.add("again"); + const token = await answered(client.auth.add("again")); expect(token.grant.peek()?.publish.equals(patterns("a"))).toBe(true); const third = await nextRoute(announced); expect(third?.kind).toBe("start"); @@ -395,7 +439,7 @@ describe.each([Lite.ALPN_07_WIP])("%s", (protocol) => { } })(); try { - await expect(client.auth.add("unsupported")).rejects.toBeInstanceOf(Unsupported); + await expect(answered(client.auth.add("unsupported"))).rejects.toBeInstanceOf(Unsupported); } finally { client.abort(); server.abort(); @@ -415,7 +459,7 @@ describe.each([Lite.ALPN_07_WIP])("%s", (protocol) => { } })(); - const err = await client.auth.add("forged").catch((e: unknown) => e); + const err = await answered(client.auth.add("forged")).catch((e: unknown) => e); expect(err).toBeInstanceOf(SessionError); expect((err as SessionError).code).toBe(SessionCode.Unauthorized); client.abort(); @@ -454,7 +498,7 @@ describe.each([Lite.ALPN_07_WIP])("%s", (protocol) => { if (!setup) throw new Error("missing setup token"); const initial = setup.accept(grant([], [])); await waitFor(client.auth.grant, (g) => g !== undefined); - const adding = client.auth.add("first"); + const adding = answered(client.auth.add("first")); try { const unanswered = await requests.next(); if (!unanswered) throw new Error("missing token"); @@ -468,7 +512,7 @@ describe.each([Lite.ALPN_07_WIP])("%s", (protocol) => { expect( logged.mock.calls.filter(([line]) => String(line).startsWith("request waited for authorization:")), ).toEqual([["request waited for authorization: path=room/x waited=7ms"]]); - const renewing = client.auth.add("renewal"); + const renewing = answered(client.auth.add("renewal")); const renewal = await requests.next(); if (!renewal) throw new Error("missing renewal"); broadcast @@ -506,7 +550,7 @@ describe.each([Lite.ALPN_07_WIP])("%s", (protocol) => { if (!setup) throw new Error("missing setup token"); const initial = setup.accept(grant([], [])); await waitFor(client.auth.grant, (g) => g !== undefined); - const adding = client.auth.add("first"); + const adding = answered(client.auth.add("first")); try { const unanswered = await requests.next(); if (!unanswered) throw new Error("missing token"); @@ -546,7 +590,7 @@ describe.each([Lite.ALPN_07_WIP])("%s", (protocol) => { if (!setup) throw new Error("missing setup token"); const initial = setup.accept(grant([], [])); await waitFor(client.auth.grant, (g) => g !== undefined); - const adding = client.auth.add("first"); + const adding = answered(client.auth.add("first")); try { const unanswered = await requests.next(); if (!unanswered) throw new Error("missing token"); @@ -585,7 +629,7 @@ describe.each([Lite.ALPN_07_WIP])("%s", (protocol) => { if (!setup) throw new Error("missing setup token"); const initial = setup.accept(grant([], [])); await waitFor(client.auth.grant, (g) => g !== undefined); - const adding = client.auth.add("first"); + const adding = answered(client.auth.add("first")); try { const unanswered = await requests.next(); if (!unanswered) throw new Error("missing token"); @@ -742,7 +786,7 @@ test("lite-07 presents a transiently refused token again after a backoff", async if (!setup) throw new Error("missing setup token"); issued.push(setup.accept(grant([], []))); - const adding = client.auth.add("token"); + const adding = answered(client.auth.add("token")); void adding.then(() => { added = true; }); @@ -791,7 +835,7 @@ test("lite-07 presents a shared token again when its retry stream is reset", asy setup?.accept(grant([], [])); let rejected: unknown; - handle.add("token").catch((err) => { + answered(handle.add("token")).catch((err) => { rejected = err; }); const request = await requests.next(); @@ -831,7 +875,7 @@ test("lite-07 presents a token added after a GOAWAY only on the successor", asyn seen.push(token); return token ? grant(["first"], []) : grant([], []); }); - const token = await handle.add("token"); + const token = await answered(handle.add("token")); await waitFor(handle.grant, (g) => g?.publish.equals(patterns("first")) === true); const stream = await Stream.open(pair.server, { version: Lite.Version.DRAFT_07 }); @@ -839,7 +883,7 @@ test("lite-07 presents a token added after a GOAWAY only on the successor", asyn await new Lite.Goaway("").encode(stream.writer, Lite.Version.DRAFT_07); await settles(() => !wireOf(client).auth.active); - const late = handle.add("late"); + const late = answered(handle.add("late")); const successor = await connect({ publish: new OriginProducer(), protocol, auth: handle }); // The successor has not answered yet, so its predecessor's grant no longer applies. expect(handle.grant.peek()).toBeUndefined(); @@ -870,8 +914,7 @@ test("lite-07 refuses a token added to a session's own handle after a GOAWAY", a await settles(() => !wireOf(client).auth.active); try { - const late = withTimeout(client.auth.add("late"), Duration.millis(1000), "add after GOAWAY hung"); - await expect(late).rejects.toBeInstanceOf(StreamError); + expect(() => client.auth.add("late")).toThrow(StreamError); } finally { client.abort(); server.abort(); @@ -890,7 +933,7 @@ test("lite-07 ignores a draining session's late refusal once its successor grant const setup = await requests.next(); if (!setup) throw new Error("missing setup token"); const issued = [setup.accept(grant([], []))]; - const adding = handle.add("token"); + const adding = answered(handle.add("token")); const unanswered = await requests.next(); expect(unanswered && new TextDecoder().decode(unanswered.token)).toBe("token"); @@ -942,7 +985,7 @@ test("lite-07 ignores a draining session's late refusal while its successor retr const setup = await requests.next(); if (!setup) throw new Error("missing setup token"); issued.push(setup.accept(grant([], []))); - const adding = handle.add("token"); + const adding = answered(handle.add("token")); const unanswered = await requests.next(); expect(unanswered && new TextDecoder().decode(unanswered.token)).toBe("token"); @@ -1056,7 +1099,7 @@ test("a fetch abandoned while its token waits never reaches the wire", async () if (!setup) throw new Error("missing setup token"); const initial = setup.accept(grant([], [])); await waitFor(client.auth.grant, (g) => g !== undefined); - const adding = client.auth.add("first"); + const adding = answered(client.auth.add("first")); try { const unanswered = await requests.next(); if (!unanswered) throw new Error("missing token"); @@ -1096,7 +1139,7 @@ test("a listing waits for its token before reaching the wire", async () => { if (!setup) throw new Error("missing setup token"); const initial = setup.accept(grant([], [])); await waitFor(client.auth.grant, (g) => g !== undefined); - const adding = client.auth.add("first"); + const adding = answered(client.auth.add("first")); try { const unanswered = await requests.next(); if (!unanswered) throw new Error("missing token"); @@ -1136,7 +1179,7 @@ test.each(Array.from({ length: 17 }, (_, gap) => gap))( if (!setup) throw new Error("missing setup token"); const initial = setup.accept(grant([], [])); await waitFor(client.auth.grant, (g) => g !== undefined); - const adding = client.auth.add("first"); + const adding = answered(client.auth.add("first")); try { const unanswered = await requests.next(); if (!unanswered) throw new Error("missing token"); @@ -1187,7 +1230,7 @@ test.each([Lite.ALPN_07_WIP])("%s carries literal and wildcard grants exactly", await waitFor(client.auth.grant, (g) => g !== undefined); for (const token of ["exact", "wildcard", "mixed", "root"]) { const [publish, subscribe] = unions[token]; - const added = await client.auth.add(token); + const added = await answered(client.auth.add(token)); const got = added.grant.peek(); expect(got?.publish.equals(parse(publish))).toBe(true); expect(got?.subscribe.equals(parse(subscribe))).toBe(true); @@ -1205,19 +1248,19 @@ test.each([ const { client, server } = await connect({ publish: new OriginProducer(), protocol }); expect(client.auth.grant.peek()).toBeUndefined(); if (kept) { - const token = await client.auth.add("token"); + const token = await answered(client.auth.add("token")); expect(token.grant.peek()).toBeUndefined(); expect(client.auth.grant.peek()).toBeUndefined(); // A request carries the bytes as added, whatever the caller does with its buffer after. const bytes = new TextEncoder().encode("bytes"); - const copied = await client.auth.add(bytes); + const copied = await answered(client.auth.add(bytes)); bytes.fill(0); expect(wireOf(client).auth.carried(copied)).toEqual(new TextEncoder().encode("bytes")); // The token ends with its session, like any other. client.abort(); expect(await token.closed).toBeInstanceOf(Error); } else { - await expect(client.auth.add("token")).rejects.toBeInstanceOf(Unsupported); + await expect(answered(client.auth.add("token"))).rejects.toBeInstanceOf(Unsupported); } client.abort(); server.abort(); @@ -1317,7 +1360,7 @@ test("withdrawn queued tokens are pruned before the next application decision", await stream.writer.close(); await stream.reader.closed; } - const live = client.auth.add("live"); + const live = answered(client.auth.add("live")); live.catch(() => void 0); const request = await requests.next(); if (!request) throw new Error("live token missing"); @@ -1356,7 +1399,7 @@ test("grants issued while a reply is blocked replace the one still queued", asyn const setup = await requests.next(); if (!setup) throw new Error("setup token missing"); setup.accept(grant(["a"], [])); - const live = client.auth.add("live"); + const live = answered(client.auth.add("live")); live.catch(() => void 0); const request = await requests.next(); if (!request) throw new Error("live token missing"); @@ -1412,7 +1455,7 @@ describe.each([Lite.ALPN_07_WIP])("AUTH violations %s", (protocol) => { await w.u53(2); }); }); - const added = pair.client.auth.add("bad").catch(() => undefined); + const added = answered(pair.client.auth.add("bad")).catch(() => undefined); try { expect( (await withTimeout(pair.transport.closed, Duration.millis(100), "malformed AUTH did not close")) diff --git a/js/net/src/auth.ts b/js/net/src/auth.ts index 613cdb95709..0da4e77890e 100644 --- a/js/net/src/auth.ts +++ b/js/net/src/auth.ts @@ -3,10 +3,11 @@ * * Each side of a moq-lite-07 session presents the credential its connection already carried * (the URL, or nothing) right after setup, and learns the {@link Grant} it earned. Older - * versions and moq-transport carry no AUTH exchange: the grant stays undefined. There {@link Handle.add} rejects with {@link Unsupported} on moq-lite, and on - * moq-transport keeps the token, with no grant, for the announcements whose route names it. + * versions and moq-transport carry no AUTH exchange: the grant stays undefined. There a + * {@link Credential.answered} rejects with {@link Unsupported} on moq-lite, and on + * moq-transport the token is kept, with no grant, for the announcements whose route names it. * {@link Handle.grant} is the union of every token this side presented, and {@link Handle.add} - * presents another without reconnecting, returning its {@link Credential}. + * presents another without reconnecting, returning its {@link Credential} at once. * * A {@link Handle} passed to `connect` as `auth`, or the one a reconnecting `Connection` owns, * outlives its sessions: each presents every token it holds, so a route naming one of its @@ -53,6 +54,14 @@ export interface Credential { * not one: the next session presents it again. */ readonly closed: PromiseLike; + /** + * Resolves once a peer first accepts the token. Rejects with {@link Unsupported} when a + * session neither carries AUTH nor lets a request carry a token (moq-lite without AUTH), + * with the peer's refusal as an `Error.Session`, with the handle's end, or once the token is + * withdrawn before an answer. On moq-transport without AUTH it resolves at once with no grant: + * only a request whose route names it carries it. + */ + readonly answered: PromiseLike; /** Withdraw the token, removing its grant from the union and remaking any request that carries it without it. */ close(): void; } diff --git a/js/net/src/auth_session.ts b/js/net/src/auth_session.ts index 4490297a224..cee4dff5d29 100644 --- a/js/net/src/auth_session.ts +++ b/js/net/src/auth_session.ts @@ -160,12 +160,11 @@ class Presented { } /** - * A token {@link Handle.present} returned before its answer. + * A token {@link Handle.present} returned, with what the connection's token rotation reads. * * @internal */ export interface Pending extends Credential { - /** Resolves once a peer accepts the token, or rejects as {@link Handle.add} does. */ readonly answered: Promise; /** Whether any session holds a grant for it now, a draining one included. */ readonly held: boolean; @@ -367,25 +366,21 @@ export class Handle { } /** - * Present another token on every session of this handle, resolving once a peer answers it. + * Present another token on every session of this handle, returning it at once; its + * {@link Credential.answered} settles on the peer's first verdict, and closing it withdraws + * it, answered or not. * * With no session live it waits for the next one. A refusal that may pass, such as an * unreachable auth server, is presented again after a backoff on moq-lite, and so is a token - * whose stream the peer drops without a verdict. Rejects with - * {@link Unsupported} when a session neither carries AUTH nor lets a request carry a token - * (moq-lite without AUTH), with the peer's refusal as an `Error.Session`, or with the - * handle's end. On moq-transport without AUTH it resolves at once with no grant: only a - * request whose route names it carries it. + * whose stream the peer drops without a verdict. Throws once the handle ended, or while its + * own session drains with no successor. */ - async add(token: string | Uint8Array): Promise { - const added = this.present(token); - await added.answered; - return added; + add(token: string | Uint8Array): Credential { + return this.present(token); } /** - * Present another token like {@link add}, returning it at once with its answer, so it can be - * withdrawn before the peer answers. + * Present another token like {@link add}, returning what the token rotation reads. * * @internal */ diff --git a/js/net/src/connection/migrate.test.ts b/js/net/src/connection/migrate.test.ts index fa4432e9be7..752156c0c04 100644 --- a/js/net/src/connection/migrate.test.ts +++ b/js/net/src/connection/migrate.test.ts @@ -373,7 +373,8 @@ test("the connection's tokens ride every session across a GOAWAY", async () => { delay: { initial: Duration.millis(1) }, }); try { - const token = await reload.auth.add("token"); + const token = reload.auth.add("token"); + await token.answered; const first = relays[0]; if (!first) throw new Error("no first dial"); expect(first.tokens).toContain("token"); diff --git a/js/net/src/connection/reload.test.ts b/js/net/src/connection/reload.test.ts index 3d6af3dac35..2a42c08d764 100644 --- a/js/net/src/connection/reload.test.ts +++ b/js/net/src/connection/reload.test.ts @@ -1032,7 +1032,7 @@ test("closing the loop keeps the tokens until the session drains", async () => { const origin = new OriginProducer(); const reload = new Reload({ url, websocket: { enabled: false }, publish: origin.consume() }); - await reload.auth.add("token"); + await reload.auth.add("token").answered; const broadcastRoute = origin.route(Path.from("room")); const broadcast = broadcastRoute.broadcast; const producer = broadcast.createTrack("video", { timescale: Timescale.MILLI }); diff --git a/js/net/src/ietf/publisher.test.ts b/js/net/src/ietf/publisher.test.ts index 13f69a59709..87c4fa5692b 100644 --- a/js/net/src/ietf/publisher.test.ts +++ b/js/net/src/ietf/publisher.test.ts @@ -1793,6 +1793,7 @@ class Tokens { const token: Credential = { grant: new Signal(undefined), closed: new Promise(() => {}), + answered: Promise.resolve(), close: () => { this.#issued.delete(token); this.ended.update((n) => n + 1); diff --git a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt index 29e4f137e62..6b86506ca0b 100644 --- a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt +++ b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt @@ -21,6 +21,13 @@ typealias ClientConfig = uniffi.moq.MoqClientConfig /** The credentials a [Client] presents, replaceable while it runs. */ typealias Tokens = uniffi.moq.MoqTokens +/** + * A request token a session presents, which a [Route] or [Claim] names with `setAuth`. Suspend on + * `answered()` for the peer's verdict; `cancel()` withdraws it unless a route or claim still names + * it. + */ +typealias Credential = uniffi.moq.MoqCredential + /** Certificate trust and the mTLS identity for a [ClientConfig]. */ typealias ClientTls = uniffi.moq.MoqClientTls @@ -54,15 +61,15 @@ typealias OriginProducer = uniffi.moq.MoqOriginProducer /** * An exact route on an origin, owning one broadcast (`broadcast()` to fill it). `announce`, - * `withdraw`, and `setCost` advertise it. Closing or cancelling it retracts it, as does ending its - * broadcast; a broadcast handle alone does not keep it announced. + * `withdraw`, `setCost`, and `setAuth` advertise it. Closing or cancelling it retracts it, as does + * ending its broadcast; a broadcast handle alone does not keep it announced. */ typealias Route = uniffi.moq.MoqRoute /** * A prefix route on an origin, from `OriginProducer.claim`: a claim that every path beneath a - * prefix can be served, yielding each request (`requested()`). `announce`, `withdraw`, and - * `setCost` advertise it. Closing or cancelling it retracts it and rejects the requests still + * prefix can be served, yielding each request (`requested()`). `announce`, `withdraw`, `setCost`, + * and `setAuth` advertise it. Closing or cancelling it retracts it and rejects the requests still * waiting. */ typealias Claim = uniffi.moq.MoqClaim diff --git a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Moq.kt b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Moq.kt index 9809bd31f14..a4760d6acaf 100644 --- a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Moq.kt +++ b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Moq.kt @@ -94,6 +94,15 @@ internal constructor( */ fun tokens(): Tokens = checkNotNull(session.tokens()) { "a client session presents tokens" } + /** + * Present a request token, for a [Route] or [Claim] to carry with `setAuth`. + * + * On moq-transport it rides each naming route's announcing request; on moq-lite it needs AUTH, + * where it earns a grant. The session presents it again on every reconnect. Suspend on + * [Credential.answered] for the peer's verdict. An empty token throws `MoqException.Config`. + */ + fun credential(token: String): Credential = session.credential(token) + /** * Request a draining close now; await `session.closed()` for its result, then [close] to * release the client. `moq-lite-05` and newer wait for the peer to receive finished tails; diff --git a/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt b/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt index 4f35c369ff5..6f51b27bd2f 100644 --- a/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt +++ b/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt @@ -1072,6 +1072,54 @@ class SmokeTest { } } + /** A credential is answered by the peer, names a route and a claim, and cancels. */ + @Test + fun `session credential lifecycle`() = runTest { + val version = listOf("moq-lite-07-wip") + Server.listen( + ServerConfig( + bind = "127.0.0.1:0", + versions = version, + tls = ServerTls(generate = listOf("localhost")), + ) + ) + .use { server -> + val accepted = async { server.server.accept()!!.accept() } + val moq = + Moq.connect( + "https://${server.localAddr}", + ClientConfig( + bind = "127.0.0.1:0", + versions = version, + tls = ClientTls(insecure = true), + once = true, + ), + ) + moq.use { + val session = accepted.await() + assertFailsWith { moq.credential("") } + val credential: Credential = moq.credential("extra") + // The server verifies no tokens of its own, so it refuses this one. + assertFailsWith { credential.answered() } + + val route = moq.route("room/x", RouteOptions(hidden = true)) + val claim = moq.session.publish().claim("live", RouteOptions(hidden = true)) + route.setAuth(credential) + claim.setAuth(credential) + route.setAuth(null) + claim.setAuth(null) + + credential.cancel() + assertFailsWith { credential.answered() } + assertFailsWith { route.setAuth(credential) } + assertFailsWith { claim.setAuth(credential) } + route.cancel() + claim.cancel() + session.cancel(0u) + } + } + } + /** * Over moq-lite-06, whose wire carries no instance, the session mints one: passing it back pins * `request` and `follow`, while its text parsed is another, named instance that nothing serves. diff --git a/py/moq-rs/README.md b/py/moq-rs/README.md index 02fbb87030f..47ec20e9979 100644 --- a/py/moq-rs/README.md +++ b/py/moq-rs/README.md @@ -165,6 +165,7 @@ client = moq.Client( - `.viewers() → Viewers`. Divide the receive estimate between watched broadcasts: `.watch(max_bps) → Viewer` claims up to a ceiling, `Viewer.rate()` reads its slice (`None` while there is no estimate), and dropping the viewer releases it. The summed ceilings are the PROBE target; `.set_target(bps)` overrides it (`0` sends none, `None` follows the viewers again). - `.grants() → GrantConsumer`. On `moq-lite-07-wip`, `.grant()` reads the peer's `Grant` and `async for grant in session.grants()` follows it. - `.tokens() → Tokens | None`. A client session's credentials: `.set([...])` replaces them without reconnecting and `await .answered()` raises the relay's refusal. + - `.credential(token) → Credential`. Present a request token for a route or claim to carry with `.set_auth(credential)`. `await .answered()` raises the peer's refusal (`Unsupported` on moq-lite without AUTH); `.cancel()`, or leaving an `async with`, withdraws it unless a route or claim still names it. ### Publishing @@ -289,10 +290,10 @@ moq-transport on every relay hop; this API does not add pre-07 capability detect - **`Route`**. An exact route on an origin, owning one broadcast. Hold it: dropping or cancelling it retracts it, and so does closing its broadcast. - `.broadcast → BroadcastProducer`. The broadcast the route owns. - `.instance → Instance | None`. The stream it serves now, kept across `withdraw()` and `announce()`, or `None` once cancelled without one. Unless one is named, a job's route takes its claim's when it first announces or when `.instance` first reads it. Read first, the job announces under it, and `announce()` fails with `Unroutable` once the claim has moved past it. - - `.announce()`, `.withdraw()`, `.set_cost(cost)`. Show, hide, or re-price it in place; `announce` and `set_cost` raise `Closed` once the broadcast closed. + - `.announce()`, `.withdraw()`, `.set_cost(cost)`, `.set_auth(credential)`. Show, hide, re-price, or swap its request credential (`None` for the session's scope) in place; `announce`, `set_cost`, and `set_auth` raise `Closed` once the broadcast closed. - **`Claim`**. A prefix route on an origin. Hold it: dropping or cancelling it retracts it and rejects the requests still waiting. - `.instance → Instance`. The stream it serves now, kept across `withdraw()` and `announce()`. Unless one is named, it moves to a fresh one each time a job holding it ends. - - `.announce()`, `.withdraw()`, `.set_cost(cost)`. Show, hide, or re-price it in place. + - `.announce()`, `.withdraw()`, `.set_cost(cost)`, `.set_auth(credential)`. Show, hide, re-price, or swap its request credential (`None` for the session's scope) in place. - `await .requested() → BroadcastRequest | None`, or async iteration. Call `.serve(consumer)` to answer with held content under the claim's continuous sequence space, `.route(hidden=..., ...)` for the path's own route, which continues the claim's instance until the job ends and then moves the claim to a fresh one (unless either names an instance: a named claim lends none, and a named job keeps its own), or `.reject(code)` to fail the requester. - **`OriginConsumer`**. Discover broadcasts. - `.announced(prefix, filter=None) → AnnounceConsumer` (async iterator of `AnnounceEvent`: `AnnounceEventPresent`, `AnnounceEventUnavailable`, `AnnounceEventAvailable`, `AnnounceEventAbsent`, or `AnnounceEventOffline`, each carrying an `Announce` whose `.route.instance` names the stream; another instance taking a path over keeps it present, so `follow` the path to see that); `filter` is a pattern relative to the literal prefix, while each `Announce.prefix` stays origin-relative and `.captures` is a required list of wildcard matches, empty without wildcards diff --git a/py/moq-rs/moq/__init__.py b/py/moq-rs/moq/__init__.py index 997c926ca91..be9c5ce0c52 100644 --- a/py/moq-rs/moq/__init__.py +++ b/py/moq-rs/moq/__init__.py @@ -45,6 +45,7 @@ from .session import ( Allocator, BandwidthConsumer, + Credential, GrantConsumer, RateControl, Reservation, @@ -128,6 +129,7 @@ "ConnectionStats", "ConnectionPhase", "ConnectionState", + "Credential", "Datagram", "DatagramInfo", "DatagramConsumer", diff --git a/py/moq-rs/moq/origin.py b/py/moq-rs/moq/origin.py index 73aec83d882..1f3bd315285 100644 --- a/py/moq-rs/moq/origin.py +++ b/py/moq-rs/moq/origin.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import TYPE_CHECKING + from moq_ffi import ( MoqAnnounce, MoqAnnounceConfig, @@ -22,6 +24,9 @@ from .subscribe import BroadcastConsumer from .types import Instance +if TYPE_CHECKING: + from .session import Credential + Announce = MoqAnnounce """A route over a prefix: the origin-relative ``prefix``, what each filter wildcard matched (the required list ``captures``), and the ``route`` serving it. @@ -211,6 +216,16 @@ def set_cost(self, cost: int) -> None: """ self._inner.set_cost(cost) + def set_auth(self, credential: Credential | None) -> None: + """Swap the credential the route's announcing request carries, renewing it in place, + or ``None`` to announce under the session's scope. + + Only the session it was minted on carries it. Raises ``moq.Error.Closed`` once its + broadcast closed, keeping the credential it had, and ``moq.Error.Cancelled`` for a + cancelled credential. + """ + self._inner.set_auth(None if credential is None else credential._inner) + def cancel(self) -> None: """Retract the route and release it now. Its broadcast lives on until :meth:`BroadcastProducer.close`.""" @@ -256,6 +271,15 @@ def set_cost(self, cost: int) -> None: """ self._inner.set_cost(cost) + def set_auth(self, credential: Credential | None) -> None: + """Swap the credential the claim's announcing request carries, renewing it in place, + or ``None`` to announce under the session's scope. + + Only the session it was minted on carries it. Raises ``moq.Error.Cancelled`` for a + cancelled credential. + """ + self._inner.set_auth(None if credential is None else credential._inner) + async def _next(self) -> BroadcastRequest | None: return await self.requested() diff --git a/py/moq-rs/moq/session.py b/py/moq-rs/moq/session.py index 2357e0263aa..53d6f34f924 100644 --- a/py/moq-rs/moq/session.py +++ b/py/moq-rs/moq/session.py @@ -7,6 +7,7 @@ from moq_ffi import ( MoqAllocator, MoqBandwidthConsumer, + MoqCredential, MoqGrantConsumer, MoqRateControl, MoqRatePolicy, @@ -112,6 +113,16 @@ def grants(self) -> GrantConsumer: each reconnect, reading ``None`` while offline.""" return GrantConsumer(self._inner.grants()) + def credential(self, token: str) -> Credential: + """Present a request token on this session, for a route or claim to carry with + :meth:`Route.set_auth` or :meth:`Claim.set_auth`. + + On moq-transport it rides each naming route's announcing request; on moq-lite it + needs AUTH, where it earns a grant. A client session presents it again on every + reconnect, and with no session live it waits for the next one. Raises + ``moq.Error.Config`` for an empty token.""" + return Credential(self._inner.credential(token)) + def tokens(self) -> Tokens | None: """The credentials a native client session presents, to replace while it runs. @@ -198,6 +209,37 @@ def cancel(self) -> None: self._inner.cancel() +class Credential: + """A request token a session presents, returned by :meth:`Session.credential`. + + It stays presented until :meth:`cancel`, or until it is dropped and no route or claim + names it, answered or not. Usable as an async context manager that cancels it on exit. + """ + + def __init__(self, inner: MoqCredential) -> None: + self._inner = inner + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc) -> None: + self.cancel() + + async def answered(self) -> None: + """Wait for the peer's first verdict on the token. + + Returns once a peer accepts it, at once on moq-transport, where only the requests + that name it carry the token. Raises the peer's refusal carrying its code, + ``moq.Error.Unsupported`` when nothing can carry a token (moq-lite without AUTH), and + ``moq.Error.Cancelled`` once :meth:`cancel` ran.""" + await self._inner.answered() + + def cancel(self) -> None: + """Release the credential now: withdrawn at once unless a route or claim still + names it, and every :meth:`answered` call raises ``moq.Error.Cancelled``.""" + self._inner.cancel() + + class Tokens: """The credentials a native client session presents, returned by :meth:`Session.tokens`.""" diff --git a/py/moq-rs/tests/test_server.py b/py/moq-rs/tests/test_server.py index b94213b6b7f..a347f3dd818 100644 --- a/py/moq-rs/tests/test_server.py +++ b/py/moq-rs/tests/test_server.py @@ -657,3 +657,54 @@ async def accept_loop() -> None: pass for session in sessions: session.cancel(0) + + +async def test_session_credential_lifecycle(): + """A credential is answered by the peer, names a route and a claim, and cancels.""" + version = ["moq-lite-07-wip"] + async with moq.Server("127.0.0.1:0", tls_generate=["localhost"], versions=version) as server: + sessions: list = [] + + async def accept_loop() -> None: + async for request in server: + sessions.append(await request.accept()) + + accept_task = asyncio.create_task(accept_loop()) + try: + async with moq.Client( + f"https://{server.local_addr}", tls=moq.ClientTls(insecure=True), bind="127.0.0.1:0", versions=version + ) as client: + session = client.session + assert session is not None + with pytest.raises(moq.Error.Config): + session.credential("") + + credential = session.credential("extra") + # The server verifies no tokens of its own, so it refuses this one. + with pytest.raises(moq.Error.Unsupported): + await asyncio.wait_for(credential.answered(), timeout=5.0) + + route = session.publish().route("room/x", hidden=True) + claim = session.publish().claim("live", hidden=True) + route.set_auth(credential) + claim.set_auth(credential) + route.set_auth(None) + claim.set_auth(None) + + credential.cancel() + with pytest.raises(moq.Error.Cancelled): + await credential.answered() + with pytest.raises(moq.Error.Cancelled): + route.set_auth(credential) + with pytest.raises(moq.Error.Cancelled): + claim.set_auth(credential) + route.cancel() + claim.cancel() + finally: + accept_task.cancel() + try: + await accept_task + except asyncio.CancelledError: + pass + for session in sessions: + session.cancel(0) diff --git a/rs/moq-c/README.md b/rs/moq-c/README.md index 49e1fd54139..cb59199348b 100644 --- a/rs/moq-c/README.md +++ b/rs/moq-c/README.md @@ -54,6 +54,9 @@ int32_t moq_session_grants(uint32_t session, moq_status_callback on_grant, void int32_t moq_session_grants_cancel(uint32_t watcher); int32_t moq_grant_info(uint32_t grant, moq_grant *dst); int32_t moq_grant_free(uint32_t grant); +int32_t moq_session_credential(uint32_t session, const uint8_t *token, uintptr_t token_len); +int32_t moq_credential_answered(uint32_t credential, moq_status_callback on_answer, void *user_data); +int32_t moq_credential_free(uint32_t credential); // Server int32_t moq_server_listen(const moq_server_config *config, moq_status_callback on_request, void *user_data); @@ -94,6 +97,7 @@ int32_t moq_route_instance(uint32_t route); int32_t moq_route_announce(uint32_t route); int32_t moq_route_withdraw(uint32_t route); int32_t moq_route_set_cost(uint32_t route, uint64_t cost); +int32_t moq_route_set_auth(uint32_t route, uint32_t credential); int32_t moq_route_free(uint32_t route); // Publishing: Claims (a prefix answered on demand; freeing one retracts it) @@ -102,6 +106,7 @@ int32_t moq_claim_instance(uint32_t claim); int32_t moq_claim_announce(uint32_t claim); int32_t moq_claim_withdraw(uint32_t claim); int32_t moq_claim_set_cost(uint32_t claim, uint64_t cost); +int32_t moq_claim_set_auth(uint32_t claim, uint32_t credential); int32_t moq_claim_free(uint32_t claim); int32_t moq_broadcast_request_path(uint32_t request, moq_string *dst); int32_t moq_broadcast_request_serve(uint32_t request, uint32_t broadcast); diff --git a/rs/moq-c/src/api.rs b/rs/moq-c/src/api.rs index 02d5924d69c..1829cf930e1 100644 --- a/rs/moq-c/src/api.rs +++ b/rs/moq-c/src/api.rs @@ -2342,6 +2342,25 @@ pub extern "C" fn moq_route_set_cost(route: u32, cost: u64) -> i32 { }) } +/// Swap the credential a route's announcing request carries, renewing that request in place. +/// +/// `credential` is a [crate::moq_session_credential] handle, or zero to announce under the +/// session's scope. Only the session it was minted on carries it, a dialed one across every +/// reconnect; any other session announces the route under its own scope. The route keeps +/// its own reference, so the handle can be freed after this call. Fails, keeping the +/// route's credential as it was, once its broadcast closed or the origin's background task +/// stopped. Returns zero on success, or a negative code on failure. +#[unsafe(no_mangle)] +pub extern "C" fn moq_route_set_auth(route: u32, credential: u32) -> i32 { + ffi::enter(move || { + let route = ffi::parse_id(route)?; + let credential = ffi::parse_id_optional(credential)?; + let state = State::lock(); + let credential = credential.map(|id| state.auth.credential(id)).transpose()?; + state.origin.route_with(route, |route| route.set_auth(credential)) + }) +} + /// Release a route handle, retracting the route. /// /// The broadcast handle stays the caller's until [moq_publish_close]. @@ -2411,6 +2430,25 @@ pub extern "C" fn moq_claim_set_cost(claim: u32, cost: u64) -> i32 { }) } +/// Swap the credential a claim's announcing request carries, renewing that request in place. +/// +/// `credential` is a [crate::moq_session_credential] handle, or zero to announce under the +/// session's scope. Only the session it was minted on carries it, a dialed one across every +/// reconnect; any other session announces the claim under its own scope. The claim keeps +/// its own reference, so the handle can be freed after this call. Fails, keeping the +/// claim's credential as it was, once the origin's background task stopped. Returns zero on +/// success, or a negative code on failure. +#[unsafe(no_mangle)] +pub extern "C" fn moq_claim_set_auth(claim: u32, credential: u32) -> i32 { + ffi::enter(move || { + let claim = ffi::parse_id(claim)?; + let credential = ffi::parse_id_optional(credential)?; + let state = State::lock(); + let credential = credential.map(|id| state.auth.credential(id)).transpose()?; + state.origin.claim_with(claim, |claim| claim.set_auth(credential)) + }) +} + /// Release a claim handle, retracting the claim. /// /// Requests not yet delivered are rejected, and its `on_request` callback fires once more diff --git a/rs/moq-c/src/auth.rs b/rs/moq-c/src/auth.rs index 89f15c17534..feccaec7118 100644 --- a/rs/moq-c/src/auth.rs +++ b/rs/moq-c/src/auth.rs @@ -8,11 +8,19 @@ use crate::ffi::{self, OnStatus}; use crate::task::Task; use crate::{Error, Id, NonZeroSlab, State, moq_string}; -/// Grant snapshots and the watchers delivering them. +/// Grant snapshots, the watchers delivering them, and the credentials routes name. #[derive(Default)] pub struct Auth { grant: NonZeroSlab, grants: NonZeroSlab, + credentials: NonZeroSlab, +} + +/// A [moq_session_credential] handle: the credential it holds, and a signal that ends every +/// [moq_credential_answered] wait once the handle is freed, so no wait keeps it presented. +struct CredentialRecord { + credential: moq_net::auth::Credential, + freed: tokio::sync::watch::Sender<()>, } /// One grant delivered to a [moq_session_grants] callback, owning the strings it lends. @@ -80,6 +88,11 @@ pub struct moq_grant { } impl Auth { + /// The credential behind a [moq_session_credential] handle. + pub fn credential(&self, id: Id) -> Result { + Ok(self.credentials.get(id).ok_or(Error::NotFound)?.credential.clone()) + } + fn grants(&mut self, handle: moq_net::auth::Handle, callback: OnStatus) -> Result { let watch = handle.grant(); self.grants.spawn( @@ -260,3 +273,97 @@ pub extern "C" fn moq_grant_free(grant: u32) -> i32 { Ok(()) }) } + +/// Present a request token on a session, for routes and claims to carry with +/// [crate::moq_route_set_auth] and [crate::moq_claim_set_auth]. +/// +/// The token is opaque bytes, presented at once and kept until the handle is freed and no +/// route or claim names it. On moq-transport it rides each naming route's announcing request +/// as its `AUTHORIZATION TOKEN`; on moq-lite it needs AUTH, where it earns a grant on the +/// session. A dialed session presents it again on every reconnect, and with no session live +/// it waits for the next one. Learn the peer's verdict with [moq_credential_answered]. +/// +/// Returns a positive credential handle to release with [moq_credential_free], or a negative +/// code: `MOQ_ERROR_INVALID_CONFIG` for an empty token, `MOQ_ERROR_OFFLINE` for an accepted +/// session still in SETUP, and the session's close reason once it ended. +/// +/// # Safety +/// - `token` must point at `token_len` readable bytes. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn moq_session_credential(session: u32, token: *const u8, token_len: usize) -> i32 { + ffi::enter(move || { + let session = ffi::parse_id(session)?; + let token = bytes::Bytes::copy_from_slice(unsafe { ffi::parse_slice(token, token_len)? }); + if token.is_empty() { + return Err(Error::InvalidConfig("a token cannot be empty".into())); + } + let mut state = State::lock(); + let credential = state.session.auth(session)?.add(token)?; + let (freed, _) = tokio::sync::watch::channel(()); + state.auth.credentials.insert(CredentialRecord { credential, freed }) + }) +} + +/// Wait for the peer's first verdict on a credential. +/// +/// `on_answer` fires exactly once: `0` once a peer accepts the token, at once on +/// moq-transport, where only requests carry it, or a negative code. A refusal carries the +/// peer's code through [crate::moq_error_protocol]; `MOQ_ERROR_UNSUPPORTED` means neither the +/// session nor its requests can carry a token, as on moq-lite without AUTH; closing the +/// session ends the wait with its close reason, and [moq_credential_free] with +/// `MOQ_ERROR_MOQ`. +/// +/// Returns zero once the wait started, or a negative code, in which case `on_answer` never +/// fires. +/// +/// # Safety +/// - `on_answer` must be non-NULL, and `user_data` valid until it fires. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn moq_credential_answered( + credential: u32, + on_answer: ffi::moq_status_callback, + user_data: *mut c_void, +) -> i32 { + ffi::enter(move || { + let credential = ffi::parse_id(credential)?; + let callback = unsafe { OnStatus::new(user_data, on_answer)? }; + let (credential, mut freed) = { + let state = State::lock(); + let record = state.auth.credentials.get(credential).ok_or(Error::NotFound)?; + (record.credential.clone(), record.freed.subscribe()) + }; + tokio::spawn(async move { + let res = ffi::run(async move { + tokio::select! { + biased; + // Only an error, once the handle's sender drops. + _ = freed.changed() => Err(Error::Moq(moq_net::Error::Cancel)), + answered = credential.answered() => answered.map_err(Error::from), + } + }) + .await; + callback.call(res); + }); + Ok(()) + }) +} + +/// Release a credential handle from [moq_session_credential], ending its +/// [moq_credential_answered] waits. +/// +/// The token stays presented while a route or claim still names it, and is withdrawn once +/// none does, answered or not. Returns zero, or a negative code if the handle is unknown. +#[unsafe(no_mangle)] +pub extern "C" fn moq_credential_free(credential: u32) -> i32 { + ffi::enter(move || { + let credential = ffi::parse_id(credential)?; + let record = State::lock() + .auth + .credentials + .remove(credential) + .ok_or(Error::NotFound)?; + // Outside the lock: withdrawing the token takes the auth handle's own. + drop(record); + Ok(()) + }) +} diff --git a/rs/moq-c/src/test.rs b/rs/moq-c/src/test.rs index d5af1140f87..0a3f220302e 100644 --- a/rs/moq-c/src/test.rs +++ b/rs/moq-c/src/test.rs @@ -9161,3 +9161,449 @@ fn preencoded_rungs_and_rate_control() { assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } + +/// What a [`base_moqt_server`] saw happen to one request token. +#[derive(Debug, PartialEq)] +enum TokenSeen { + /// The peer presented it, and it was granted in full. + Presented(Vec), + /// The peer withdrew it, or the last request naming it ended. + Withdrawn(Vec), +} + +/// Listen over TCP for one moq-transport `version` session as a base moq-transport peer, +/// declining the Solicit extension so only requests carry tokens, and consuming +/// the peer's broadcasts into the moq-c origin `sink`. Returns the address and what happens +/// to each non-empty token the session presents. +fn base_moqt_server(version: &str, sink: u32) -> (String, mpsc::Receiver) { + let version: moq_net::Version = version.parse().unwrap(); + let mut config = moq_tokio::listen::Config::default(); + config.tcp.bind = Some("127.0.0.1:0".parse().unwrap()); + config.version = vec![version]; + config.extensions.solicit = false; + let mut listener = crate::ffi::RUNTIME + .block_on(async { config.init(Default::default()).unwrap().listen().await }) + .unwrap(); + let addr = listener.tcp_local_addr().unwrap().to_string(); + let sink = State::lock() + .origin + .get(crate::ffi::parse_id(sink).unwrap()) + .unwrap() + .producer + .clone(); + + let (tx, rx) = mpsc::channel(); + crate::ffi::RUNTIME.spawn(async move { + let mut request = listener.accept().await.unwrap(); + let mut requests = request.requests(); + let _session = request.with_subscriber(sink).ok().await.unwrap(); + // The connection credential's grant, held for the session. + let mut held = Vec::new(); + while let Some(request) = requests.next().await { + let token = request.token().to_vec(); + let issued = request.accept(moq_net::auth::Grant::all()); + if token.is_empty() { + held.push(issued); + continue; + } + if tx.send(TokenSeen::Presented(token.clone())).is_err() { + break; + } + let tx = tx.clone(); + tokio::spawn(async move { + issued.closed().await; + let _ = tx.send(TokenSeen::Withdrawn(token)); + }); + } + }); + (addr, rx) +} + +/// Dial `addr` over `version`, publishing `source`, without waiting for SETUP. A session +/// not dialed `once` redials at once whenever it drops. +fn dial_tcp(addr: &str, version: &str, source: u32, once: bool) -> (u32, States) { + let versions = [moq_str(version)]; + let mut config = client_config(); + config.versions = versions.as_ptr(); + config.versions_len = versions.len(); + config.once = once; + config.backoff_initial_us = 1_000; + config.has_backoff_initial = true; + config.backoff_max_us = 1_000; + config.has_backoff_max = true; + let url = format!("tcp://{addr}/"); + let states = States::new(); + let client = id(unsafe { + moq_session_connect( + url.as_ptr() as *const c_char, + url.len(), + &config, + source, + 0, + Some(state_callback), + states.ptr, + ) + }); + (client, states) +} + +/// Dial `addr` once over `version`, publishing `source`, and wait until connected. +fn dial_once(addr: &str, version: &str, source: u32) -> (u32, States) { + let (client, states) = dial_tcp(addr, version, source, true); + assert_eq!( + states.recv().phase, + moq_connection_phase::MOQ_CONNECTION_PHASE_CONNECTED, + "{version}" + ); + (client, states) +} + +/// Wait for `states` to report the session connected again. +fn recv_connected(states: &States) -> Seen { + loop { + let seen = states.recv(); + if seen.phase == moq_connection_phase::MOQ_CONNECTION_PHASE_CONNECTED { + return seen; + } + } +} + +/// Mint a credential on `session` for `token`. +fn mint(session: u32, token: &[u8]) -> u32 { + id(unsafe { moq_session_credential(session, token.as_ptr(), token.len()) }) +} + +/// Start waiting for the verdict on `credential`, delivered to the returned callback. +fn answering(credential: u32) -> ProtocolCallback { + let cb = ProtocolCallback::new(); + assert_eq!( + unsafe { moq_credential_answered(credential, Some(protocol_callback), cb.ptr) }, + 0 + ); + cb +} + +/// Mint a credential on `session` for `token`, waiting for the peer to accept it. +fn credential(session: u32, token: &[u8]) -> u32 { + let credential = mint(session, token); + assert_eq!(answering(credential).recv().0, 0); + credential +} + +/// A moq-lite server whose test answers every token: each `admit` accepts the next session, +/// ending the one before, and each non-empty token a session presents arrives on `tokens`. +struct AuthServer { + addr: String, + admit: tokio::sync::mpsc::UnboundedSender<()>, + tokens: mpsc::Receiver, +} + +impl AuthServer { + /// Listen over TCP for `version`, accepting no session until the first `admit`. + fn new(version: &str) -> Self { + let version: moq_net::Version = version.parse().unwrap(); + let mut config = moq_tokio::listen::Config::default(); + config.tcp.bind = Some("127.0.0.1:0".parse().unwrap()); + config.version = vec![version]; + let mut listener = crate::ffi::RUNTIME + .block_on(async { config.init(Default::default()).unwrap().listen().await }) + .unwrap(); + let addr = listener.tcp_local_addr().unwrap().to_string(); + let (admit, mut admits) = tokio::sync::mpsc::unbounded_channel::<()>(); + let (tx, tokens) = mpsc::channel(); + crate::ffi::RUNTIME.spawn(async move { + let mut live: Option<(moq_net::Session, tokio::task::JoinHandle<()>)> = None; + while admits.recv().await.is_some() { + if let Some((session, forward)) = live.take() { + session.abort(moq_net::Error::Cancel); + forward.abort(); + } + let mut request = listener.accept().await.unwrap(); + let mut requests = request.requests(); + let session = request.ok().await.unwrap(); + let tx = tx.clone(); + let forward = tokio::spawn(async move { + // The connection credential's grant, held for the session. + let mut setup = Vec::new(); + while let Some(request) = requests.next().await { + if request.token().is_empty() { + setup.push(request.accept(moq_net::auth::Grant::all())); + } else if tx.send(request).is_err() { + break; + } + } + }); + live = Some((session, forward)); + } + }); + Self { addr, admit, tokens } + } + + /// Accept the next session, ending the current one. + fn admit(&self) { + self.admit.send(()).unwrap(); + } + + /// The next token a session presented. + fn token(&self) -> moq_net::auth::Request { + self.tokens.recv_timeout(TIMEOUT).expect("no token presented") + } + + /// Wait for `closed`: a token's `closed()`, once its presenter withdraws it. + fn withdrawn(closed: impl std::future::Future) { + crate::ffi::RUNTIME + .block_on(async { kio::clock::timeout(TIMEOUT, closed).await }) + .expect("the token stayed presented"); + } +} + +/// A route's credential rides its moq-transport PUBLISH_NAMESPACE to the peer's acceptor, +/// and swapping it renews the announcement on the same session, withdrawing the old token. +#[test] +fn a_route_credential_rides_and_renews_the_moq_transport_announce() { + for version in ["moq-transport-18", "moq-transport-22"] { + let sink = id(moq_origin_create()); + let (addr, seen) = base_moqt_server(version, sink); + let source = id(moq_origin_create()); + let (client, states) = dial_once(&addr, version, source); + + let (route, broadcast) = create_route(source, b"room/x", Some(&hidden())); + let first = credential(client, b"first"); + assert_eq!(moq_route_set_auth(route, first), 0); + assert_eq!(moq_credential_free(first), 0, "the route keeps its own reference"); + assert_eq!(moq_route_announce(route), 0); + assert_eq!( + seen.recv_timeout(TIMEOUT).unwrap(), + TokenSeen::Presented(b"first".to_vec()), + "{version}" + ); + + let second = credential(client, b"second"); + assert_eq!(moq_route_set_auth(route, second), 0); + assert_eq!(moq_credential_free(second), 0); + assert_eq!( + seen.recv_timeout(TIMEOUT).unwrap(), + TokenSeen::Presented(b"second".to_vec()), + "{version}" + ); + assert_eq!( + seen.recv_timeout(TIMEOUT).unwrap(), + TokenSeen::Withdrawn(b"first".to_vec()), + "{version}" + ); + // The renewed announcement still routes the broadcast to the peer. + assert_eq!(moq_consume_close(request_broadcast(sink, b"room/x")), 0); + + // The one connection the server accepts carried both tokens, so the session held. + assert_eq!(moq_session_close(client), 0); + states.recv_terminal(); + assert_eq!(moq_route_free(route), 0); + assert_eq!(moq_publish_close(broadcast), 0); + assert_eq!(moq_origin_close(source), 0); + assert_eq!(moq_origin_close(sink), 0); + } +} + +/// A claim's credential rides its announcement too, and zero takes it off again. +#[test] +fn a_claim_credential_rides_the_moq_transport_announce() { + let sink = id(moq_origin_create()); + let (addr, seen) = base_moqt_server("moq-transport-18", sink); + let source = id(moq_origin_create()); + let (client, states) = dial_once(&addr, "moq-transport-18", source); + + let claim_cb = Callback::new(); + let options = hidden(); + let prefix = "room"; + let claim = id(unsafe { + moq_origin_claim( + source, + prefix.as_ptr() as *const c_char, + prefix.len(), + &options, + Some(channel_callback), + claim_cb.ptr, + ) + }); + let token = credential(client, b"claimed"); + assert_eq!(moq_claim_set_auth(claim, token), 0); + assert_eq!(moq_claim_announce(claim), 0); + assert_eq!( + seen.recv_timeout(TIMEOUT).unwrap(), + TokenSeen::Presented(b"claimed".to_vec()) + ); + + assert_eq!(moq_claim_set_auth(claim, 0), 0); + assert_eq!(moq_credential_free(token), 0); + assert_eq!( + moq_credential_free(token), + MOQ_ERROR_NOT_FOUND, + "a freed handle is gone" + ); + assert_eq!( + seen.recv_timeout(TIMEOUT).unwrap(), + TokenSeen::Withdrawn(b"claimed".to_vec()), + "zero announces under the session's scope" + ); + + assert_eq!(moq_session_close(client), 0); + states.recv_terminal(); + assert_eq!(moq_claim_free(claim), 0); + assert_eq!(claim_cb.recv_terminal(), 0); + assert_eq!(moq_origin_close(source), 0); + assert_eq!(moq_origin_close(sink), 0); +} + +/// An empty token mints no credential, and an unknown session none either. +#[test] +fn a_credential_needs_a_token_and_a_session() { + assert_eq!( + unsafe { moq_session_credential(1, std::ptr::null(), 0) }, + MOQ_ERROR_INVALID_CONFIG + ); + let token = b"token"; + assert!(unsafe { moq_session_credential(u32::MAX >> 1, token.as_ptr(), token.len()) } < 0); + assert_eq!( + unsafe { moq_credential_answered(u32::MAX >> 1, Some(ignore_callback), std::ptr::null_mut()) }, + MOQ_ERROR_NOT_FOUND + ); +} + +/// On moq-lite 07 the peer's AUTH verdict answers a credential: a grant, or its refusal code. +#[test] +fn a_moq_lite_peer_answers_a_credential_over_auth() { + let server = AuthServer::new("moq-lite-07-wip"); + server.admit(); + let (client, states) = dial_once(&server.addr, "moq-lite-07-wip", 0); + + let granted = mint(client, b"granted"); + let answer = answering(granted); + let request = server.token(); + assert_eq!(request.token().as_ref(), b"granted"); + let _issued = request.accept(moq_net::auth::Grant::all()); + assert_eq!(answer.recv().0, 0); + + let refused = mint(client, b"refused"); + let answer = answering(refused); + let request = server.token(); + assert_eq!(request.token().as_ref(), b"refused"); + request.reject(moq_net::SessionError::Unauthorized, "no"); + let (code, protocol) = answer.recv(); + assert!(code < 0, "a refusal is an error, got {code}"); + assert_eq!( + protocol.map(|protocol| protocol.kind), + Some(moq_protocol_kind::MOQ_PROTOCOL_KIND_UNAUTHORIZED as u32), + "the refusal carries the peer's code" + ); + // A credential already answered reports its verdict again at once. + assert_eq!(answering(granted).recv().0, 0); + + assert_eq!(moq_credential_free(granted), 0); + assert_eq!(moq_credential_free(refused), 0); + assert_eq!(moq_session_close(client), 0); + states.recv_terminal(); +} + +/// moq-lite before 07 has no AUTH, and its requests carry no token, so nothing can. +#[test] +fn a_moq_lite_peer_without_auth_cannot_carry_a_credential() { + let server = AuthServer::new("moq-lite-06"); + server.admit(); + let (client, states) = dial_once(&server.addr, "moq-lite-06", 0); + + let credential = mint(client, b"token"); + let (code, _) = answering(credential).recv(); + assert_eq!(code, MOQ_ERROR_UNSUPPORTED); + + assert_eq!(moq_credential_free(credential), 0); + assert_eq!(moq_session_close(client), 0); + states.recv_terminal(); +} + +/// A dialed session presents its credentials again on every reconnect, with no new mint. +#[test] +fn a_credential_rides_every_reconnect() { + let server = AuthServer::new("moq-lite-07-wip"); + server.admit(); + let (client, states) = dial_tcp(&server.addr, "moq-lite-07-wip", 0, false); + recv_connected(&states); + + let credential = mint(client, b"kept"); + let answer = answering(credential); + let first = server.token(); + assert_eq!(first.token().as_ref(), b"kept"); + let _first = first.accept(moq_net::auth::Grant::all()); + assert_eq!(answer.recv().0, 0); + + // The server ends the session; the client redials and presents the token again. + server.admit(); + let again = server.token(); + assert_eq!(again.token().as_ref(), b"kept", "the reconnect presents it again"); + let again = again.accept(moq_net::auth::Grant::all()); + assert_eq!(recv_connected(&states).connects, 2); + + assert_eq!(moq_credential_free(credential), 0); + AuthServer::withdrawn(again.closed()); + assert_eq!(moq_session_close(client), 0); + states.recv_terminal(); +} + +/// Freeing a credential still waiting for a session withdraws it before any peer sees it, +/// and ends its wait. +#[test] +fn freeing_a_credential_offline_cancels_it() { + let server = AuthServer::new("moq-lite-07-wip"); + // Nothing answers SETUP yet, so the session stays offline. + let (client, states) = dial_tcp(&server.addr, "moq-lite-07-wip", 0, true); + + let cancelled = mint(client, b"cancelled"); + let answer = answering(cancelled); + assert_eq!(moq_credential_free(cancelled), 0); + assert_eq!(answer.recv().0, MOQ_ERROR_MOQ, "freeing ends the wait"); + + server.admit(); + recv_connected(&states); + let later = mint(client, b"later"); + assert_eq!( + server.token().token().as_ref(), + b"later", + "the freed token never reached the peer" + ); + assert_eq!(moq_credential_free(later), 0); + assert_eq!(moq_session_close(client), 0); + states.recv_terminal(); +} + +/// Freeing a credential the peer has not answered yet withdraws it from the peer and ends +/// its wait. +#[test] +fn freeing_a_credential_awaiting_auth_cancels_it() { + let server = AuthServer::new("moq-lite-07-wip"); + server.admit(); + let (client, states) = dial_once(&server.addr, "moq-lite-07-wip", 0); + + let held = mint(client, b"held"); + let answer = answering(held); + let request = server.token(); + assert_eq!(request.token().as_ref(), b"held"); + assert_eq!(moq_credential_free(held), 0); + assert_eq!(answer.recv().0, MOQ_ERROR_MOQ, "freeing ends the wait"); + AuthServer::withdrawn(request.closed()); + + assert_eq!(moq_session_close(client), 0); + states.recv_terminal(); +} + +/// Closing a session ends the wait of a credential still waiting for it. +#[test] +fn a_pending_credential_ends_with_its_session() { + let server = AuthServer::new("moq-lite-07-wip"); + let (client, states) = dial_tcp(&server.addr, "moq-lite-07-wip", 0, true); + let credential = mint(client, b"pending"); + let answer = answering(credential); + assert_eq!(moq_session_close(client), 0); + let (code, _) = answer.recv(); + assert!(code < 0, "expected a negative code, got {code}"); + states.recv_terminal(); + assert_eq!(moq_credential_free(credential), 0); +} diff --git a/rs/moq-ffi/src/auth.rs b/rs/moq-ffi/src/auth.rs index bbe95ec646b..78282b6ef16 100644 --- a/rs/moq-ffi/src/auth.rs +++ b/rs/moq-ffi/src/auth.rs @@ -79,6 +79,52 @@ impl MoqGrantConsumer { } } +/// A token this side presented on a session, which a route or claim names with `set_auth`. +/// +/// Returned by [`MoqSession::credential`](crate::session::MoqSession::credential). It stays +/// presented until [`cancel`](Self::cancel) or the last handle is released and no route or +/// claim names it, answered or not. +#[derive(uniffi::Object)] +pub struct MoqCredential { + /// The credential itself, released on cancel. + inner: Polled, +} + +impl MoqCredential { + pub(crate) fn new(credential: moq_net::auth::Credential) -> Self { + Self { + inner: Polled::new(credential), + } + } + + /// The credential to name on a route or claim, refused with `Cancelled` once cancelled. + pub(crate) fn credential(&self) -> Result { + self.inner.peek(Clone::clone).ok_or(MoqError::Cancelled) + } +} + +#[uniffi::export] +impl MoqCredential { + /// Wait for the peer's first verdict on the token: `Ok` once a peer accepts it, at once on + /// moq-transport, where only the requests that name it carry the token. + /// + /// The peer's refusal is a [`MoqError::Protocol`] carrying its code, + /// [`MoqError::Unsupported`] when neither the session nor its requests can carry a token + /// (moq-lite without AUTH), and [`MoqError::Cancelled`] once [`cancel`](Self::cancel) ran. + pub async fn answered(&self) -> Result<(), MoqError> { + self.inner + .wait(|credential, waiter| credential.poll_answered(waiter).map_err(Into::into)) + .await + } + + /// Release the credential here, not when the handle is: withdrawn at once unless a route + /// or claim still names it, and every [`answered`](Self::answered) call ends with + /// `Cancelled`. Terminal. + pub fn cancel(&self) { + self.inner.cancel(); + } +} + #[cfg(test)] mod tests { use std::time::Duration; diff --git a/rs/moq-ffi/src/origin.rs b/rs/moq-ffi/src/origin.rs index d0b8d4db89e..29b3e4a8ee4 100644 --- a/rs/moq-ffi/src/origin.rs +++ b/rs/moq-ffi/src/origin.rs @@ -558,6 +558,18 @@ impl MoqRoute { self.with(|route| route.set_cost(cost)) } + /// Swap the credential the route's announcing request carries, renewing that request in + /// place, or `None` to announce under the session's scope. + /// + /// Only the session it was minted on carries it, a client session across every + /// reconnect; any other session announces the route under its own scope. Errors with + /// `Closed` once its broadcast closed, keeping the credential it had, and with + /// `Cancelled` for a cancelled credential. + pub fn set_auth(&self, credential: Option>) -> Result<(), MoqError> { + let credential = credential.map(|credential| credential.credential()).transpose()?; + self.with(|route| route.set_auth(credential)) + } + /// Retract the route and release it here, not when the handle is. /// /// Terminal: later calls fail with `Cancelled`. The broadcast lives on for its other @@ -631,6 +643,17 @@ impl MoqClaim { self.with(|claim| claim.set_cost(cost)) } + /// Swap the credential the claim's announcing request carries, renewing that request in + /// place, or `None` to announce under the session's scope. + /// + /// Only the session it was minted on carries it, a client session across every + /// reconnect; any other session announces the claim under its own scope. Errors with + /// `Cancelled` for a cancelled credential. + pub fn set_auth(&self, credential: Option>) -> Result<(), MoqError> { + let credential = credential.map(|credential| credential.credential()).transpose()?; + self.with(|claim| claim.set_auth(credential)) + } + /// Wait for the next broadcast a consumer requests beneath this claim. /// /// Serve a held broadcast, route a new one, or reject the returned request; the requesting diff --git a/rs/moq-ffi/src/session.rs b/rs/moq-ffi/src/session.rs index e5252fd622f..74ff7cf0ee2 100644 --- a/rs/moq-ffi/src/session.rs +++ b/rs/moq-ffi/src/session.rs @@ -5,7 +5,7 @@ use std::time::Duration; use url::Url; -use crate::auth::MoqGrantConsumer; +use crate::auth::{MoqCredential, MoqGrantConsumer}; use crate::bandwidth::MoqAllocator; use crate::error::MoqError; use crate::ffi::Task; @@ -1107,6 +1107,30 @@ impl MoqSession { })) } + /// Present a request token on this session, for a route or claim to carry with + /// `set_auth`. + /// + /// On moq-transport it rides each naming route's announcing request as its + /// `AUTHORIZATION TOKEN`; on moq-lite it needs AUTH, where it earns a grant on the + /// session. A client session presents it again on every reconnect, and with no session + /// live it waits for the next one. Learn the peer's verdict with + /// [`MoqCredential::answered`]. + /// + /// Errors with `Config` for an empty token, and with the session's end once it closed. + pub fn credential(&self, token: String) -> Result, MoqError> { + let _guard = crate::ffi::enter(); + if token.is_empty() { + return Err(MoqError::Config { + reason: "a token cannot be empty".into(), + }); + } + let auth = match &self.inner { + Inner::Connection(connection) => connection.auth(), + Inner::Session(session) => session.auth(), + }; + Ok(Arc::new(MoqCredential::new(auth.add(token)?))) + } + /// The credentials a client session presents, to replace while it runs. /// /// `None` for a server-accepted session, which presents none. diff --git a/rs/moq-ffi/src/test.rs b/rs/moq-ffi/src/test.rs index 7c32f45b99b..524db0c03c6 100644 --- a/rs/moq-ffi/src/test.rs +++ b/rs/moq-ffi/src/test.rs @@ -8847,6 +8847,199 @@ async fn auth_tokens_join_and_leave_the_union() { .unwrap(); } +/// A TCP server for one session on `version` whose test answers every non-empty token the +/// client presents, in band or on a request. Declines Solicit, so on moq-transport only +/// requests carry tokens. +async fn token_server( + version: &str, +) -> ( + std::net::SocketAddr, + tokio::sync::mpsc::UnboundedReceiver, + tokio::task::JoinHandle<()>, +) { + let version: moq_net::Version = version.parse().unwrap(); + let mut config = moq_tokio::listen::Config::default(); + config.tcp.bind = Some("127.0.0.1:0".parse().unwrap()); + config.version = vec![version]; + config.extensions.solicit = false; + let mut listener = config.init(Default::default()).unwrap().listen().await.unwrap(); + let addr = listener.tcp_local_addr().unwrap(); + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + let serving = tokio::spawn(async move { + let mut request = listener.accept().await.expect("accept"); + let mut requests = request.requests(); + let (sink, _driver) = moq_net::origin::Producer::new(moq_net::origin::Config::default()); + let session = request.with_subscriber(sink).ok().await.unwrap(); + let mut setup = Vec::new(); + while let Some(request) = requests.next().await { + if request.token().is_empty() { + setup.push(request.accept(moq_net::auth::Grant::all())); + } else if tx.send(request).is_err() { + break; + } + } + drop((session, setup)); + }); + (addr, rx, serving) +} + +/// Dial `addr` once over TCP on `version`. +async fn token_client(addr: std::net::SocketAddr, version: &str) -> Arc { + let client = MoqClient::new(MoqClientConfig { + versions: vec![version.into()], + bind: Some("127.0.0.1:0".into()), + once: true, + ..Default::default() + }) + .unwrap(); + kio::clock::timeout(TIMEOUT, client.connect(format!("tcp://{addr}"))) + .await + .expect("connect timed out") + .unwrap() +} + +/// The next token the client presented. +async fn next_token( + tokens: &mut tokio::sync::mpsc::UnboundedReceiver, +) -> moq_net::auth::Request { + kio::clock::timeout(TIMEOUT, tokens.recv()) + .await + .expect("no token presented") + .expect("the server ended") +} + +/// On moq-lite 07 a credential's answer is the peer's AUTH verdict, and cancelling one the +/// peer holds withdraws it and ends the wait. +#[tokio::test] +async fn a_credential_is_answered_over_auth_and_cancelled_while_held() { + let (addr, mut tokens, serving) = token_server("moq-lite-07-wip").await; + let session = token_client(addr, "moq-lite-07-wip").await; + assert!(matches!( + session.credential(String::new()), + Err(MoqError::Config { .. }) + )); + + let granted = session.credential("granted".into()).unwrap(); + let request = next_token(&mut tokens).await; + assert_eq!(request.token().as_ref(), b"granted"); + let _issued = request.accept(moq_net::auth::Grant::all()); + kio::clock::timeout(TIMEOUT, granted.answered()).await.unwrap().unwrap(); + + let refused = session.credential("refused".into()).unwrap(); + next_token(&mut tokens) + .await + .reject(moq_net::SessionError::Unauthorized, "no"); + let err = kio::clock::timeout(TIMEOUT, refused.answered()) + .await + .unwrap() + .expect_err("refused"); + assert_protocol( + &err, + crate::error::MoqErrorScope::Session, + MoqProtocolKind::Unauthorized, + ); + + let held = session.credential("held".into()).unwrap(); + let request = next_token(&mut tokens).await; + let waiting = tokio::spawn({ + let held = held.clone(); + async move { held.answered().await } + }); + held.cancel(); + let result = kio::clock::timeout(TIMEOUT, waiting).await.unwrap().unwrap(); + assert!(matches!(result, Err(MoqError::Cancelled)), "{result:?}"); + kio::clock::timeout(TIMEOUT, request.closed()) + .await + .expect("cancelling withdraws the token"); + let route = session + .publish() + .route( + "room/x".into(), + MoqRouteOptions { + hidden: true, + ..Default::default() + }, + ) + .unwrap(); + assert!(matches!(route.set_auth(Some(held.clone())), Err(MoqError::Cancelled))); + + session.cancel(0); + kio::clock::timeout(TIMEOUT, serving) + .await + .expect("server ended") + .unwrap(); +} + +/// On moq-lite before 07 nothing can carry a credential. +#[tokio::test] +async fn a_credential_without_auth_is_unsupported() { + let (addr, _tokens, serving) = token_server("moq-lite-06").await; + let session = token_client(addr, "moq-lite-06").await; + let credential = session.credential("token".into()).unwrap(); + let err = kio::clock::timeout(TIMEOUT, credential.answered()) + .await + .unwrap() + .expect_err("unsupported"); + assert!(matches!(err, MoqError::Unsupported), "{err:?}"); + session.cancel(0); + serving.abort(); +} + +/// On moq-transport a route's and a claim's credentials ride their announcements, and +/// swapping one renews the request, withdrawing the old token. +#[tokio::test] +async fn route_and_claim_credentials_ride_the_moq_transport_announce() { + let (addr, mut tokens, serving) = token_server("moq-transport-18").await; + let session = token_client(addr, "moq-transport-18").await; + let hidden = || MoqRouteOptions { + hidden: true, + ..Default::default() + }; + + let first = session.credential("first".into()).unwrap(); + kio::clock::timeout(TIMEOUT, first.answered()).await.unwrap().unwrap(); + let route = session.publish().route("room/x".into(), hidden()).unwrap(); + route.set_auth(Some(first.clone())).unwrap(); + first.cancel(); + route.announce().unwrap(); + let presented = next_token(&mut tokens).await; + assert_eq!( + presented.token().as_ref(), + b"first", + "the route keeps its own reference" + ); + let presented = presented.accept(moq_net::auth::Grant::all()); + + let second = session.credential("second".into()).unwrap(); + route.set_auth(Some(second)).unwrap(); + let renewed = next_token(&mut tokens).await; + assert_eq!(renewed.token().as_ref(), b"second"); + let _renewed = renewed.accept(moq_net::auth::Grant::all()); + kio::clock::timeout(TIMEOUT, presented.closed()) + .await + .expect("the old token is withdrawn"); + + let claimed = session.credential("claimed".into()).unwrap(); + let claim = session.publish().claim("live".into(), hidden()).unwrap(); + claim.set_auth(Some(claimed)).unwrap(); + claim.announce().unwrap(); + let presented = next_token(&mut tokens).await; + assert_eq!(presented.token().as_ref(), b"claimed"); + let presented = presented.accept(moq_net::auth::Grant::all()); + claim.set_auth(None).unwrap(); + kio::clock::timeout(TIMEOUT, presented.closed()) + .await + .expect("None announces under the session's scope"); + + route.cancel(); + claim.cancel(); + session.cancel(0); + kio::clock::timeout(TIMEOUT, serving) + .await + .expect("server ended") + .unwrap(); +} + /// A time anchor starts at the group containing it, and an untimed track refuses one. #[tokio::test] async fn time_anchors_start_at_the_containing_group() { diff --git a/rs/moq-net/benches/auth.rs b/rs/moq-net/benches/auth.rs index 3b19ff6e40e..91f3f11b8c8 100644 --- a/rs/moq-net/benches/auth.rs +++ b/rs/moq-net/benches/auth.rs @@ -72,7 +72,7 @@ async fn pair(version: Version, origins: Option>) -> Pair { impl Pair { async fn round(&mut self, count: usize) { let handle = self.client.auth(); - let present = join_all((0..count).map(|_| handle.add(Bytes::from_static(b"token")))); + let present = join_all((0..count).map(|_| added(&handle, Bytes::from_static(b"token")))); let accept = async { let mut issued = Vec::with_capacity(count); for _ in 0..count { @@ -184,8 +184,8 @@ fn renewal(c: &mut Criterion) { let mut auths = Vec::new(); for route in 0..routes { auths.push([ - handle.add(format!("a/{route}")).await.unwrap(), - handle.add(format!("b/{route}")).await.unwrap(), + added(&handle, format!("a/{route}")).await.unwrap(), + added(&handle, format!("b/{route}")).await.unwrap(), ]); } auths @@ -267,7 +267,7 @@ fn waiting(c: &mut Criterion) { runtime.block_on(async { let handle = pair.client.auth(); let mut presenting = - std::pin::pin!(join_all((0..tokens).map(|_| handle.add("pending")))); + std::pin::pin!(join_all((0..tokens).map(|_| added(&handle, "pending")))); assert!(futures::poll!(presenting.as_mut()).is_pending()); let mut pending = Vec::with_capacity(tokens); for _ in 0..tokens { @@ -354,7 +354,7 @@ fn shared_requests(c: &mut Criterion) { let auths = runtime.block_on(async { let mut auths = Vec::new(); for token in 0..tokens { - auths.push(client.auth().add(format!("token/{token}")).await.unwrap()); + auths.push(added(&client.auth(), format!("token/{token}")).await.unwrap()); } auths }); @@ -404,3 +404,13 @@ fn shared_requests(c: &mut Criterion) { criterion_group!(benches, admission, renewal, waiting, shared_requests); criterion_main!(benches); + +/// Present `token` on `handle` and wait for its first verdict. +async fn added( + handle: &moq_net::auth::Handle, + token: impl Into, +) -> moq_net::Result { + let credential = handle.add(token)?; + credential.answered().await?; + Ok(credential) +} diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index e5d83388af6..37892207b94 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -29,9 +29,9 @@ //! request is held to the session's scope, which a token carried by a request never joins. //! //! Older moq-lite versions and moq-transport carry no AUTH exchange: there the grant stays -//! `None`. On moq-transport `add` still succeeds, with a grant that stays unknown, since a -//! request that names the auth carries its token; on moq-lite without AUTH it fails with -//! [`Error::Unsupported`]. moq-lite carries a grant's patterns as they are, and refuses one +//! `None`. On moq-transport a [`Credential`] is still answered, with a grant that stays +//! unknown, since a request that names it carries its token; on moq-lite without AUTH its +//! answer is [`Error::Unsupported`]. moq-lite carries a grant's patterns as they are, and refuses one //! too large for a single AUTH_OK rather than trim it. use std::{ @@ -465,7 +465,7 @@ impl State { self.links.values().rev().find(|link| link.closed.is_none()) } - /// Record a session's answer to token `id`: an acceptance settles the application's `add`, + /// Record a session's answer to token `id`: an acceptance settles [`Credential::answered`], /// and a refusal is the verdict that ends the token on every session. fn answer(&mut self, id: u64, answer: Result) { match answer { @@ -698,29 +698,19 @@ impl Handle { Watch::new(self.state.clone(), Selector::Union) } - /// Present another token on every session of this handle, resolving once a peer accepts it. + /// Present another token on every session of this handle, returning it at once. /// - /// The grant joins the union while any clone of the returned [`Credential`] lives. With no - /// session live it waits for the next one. A refusal that may pass, such as an - /// unreachable auth server, is presented again after a backoff, and so is a token whose - /// stream the peer drops without a verdict. On moq-transport - /// without AUTH there is no answer to wait for: it resolves at once with a grant that - /// stays unknown, and only a request that names it carries the token. + /// The grant joins the union while any clone of the returned [`Credential`] lives; + /// dropping the last withdraws it, answered or not. With no session live it waits for the + /// next one. [`Credential::answered`] resolves on the first verdict. A refusal that may + /// pass, such as an unreachable auth server, is presented again after a backoff, and so is + /// a token whose stream the peer drops without a verdict. /// /// # Errors /// - /// [`Error::Unsupported`] when neither the session nor its requests can carry a token - /// (moq-lite without AUTH, or a peer that takes no tokens in band), the peer's refusal - /// as [`Error::Session`], or the handle's end. - #[cfg(feature = "async")] - pub async fn add(&self, token: impl Into) -> Result { - let auth = self.present(token.into())?; - kio::wait(|waiter| auth.poll_answered(waiter)).await?; - Ok(auth) - } - - /// Register a token and queue it on every open session. - pub(crate) fn present(&self, token: Bytes) -> Result { + /// The handle's end, or [`Error::GoingAway`] when its one session is draining. + pub fn add(&self, token: impl Into) -> Result { + let token = token.into(); let mut guard = self.state.lock(); let state = &mut *guard; if let Some(err) = &state.closed { @@ -972,7 +962,7 @@ impl Link { /// alone, or else one of the handle's, presented on every session. pub(crate) fn present(&self, token: Bytes, setup: bool) -> Result { if !setup { - return self.handle.present(token); + return self.handle.add(token); } let id = self.with_state(|state, link| { let id = state.next_id; @@ -1554,9 +1544,22 @@ impl Credential { Poll::Ready(selector.ended(&state).unwrap_or(Error::Cancel)) } + /// Wait for the peer's first verdict on the token: `Ok` once a peer accepts it, at once on + /// moq-transport without AUTH, where only a request that names it carries the token. + /// + /// # Errors + /// + /// [`Error::Unsupported`] when neither the session nor its requests can carry a token + /// (moq-lite without AUTH, or a peer that takes no tokens in band), the peer's refusal as + /// [`Error::Session`], or the handle's end. + #[cfg_attr(feature = "async", kio::wrapper)] + pub fn poll_answered(&self, waiter: &kio::Waiter) -> Poll> { + self.poll_answer(waiter).map(|answer| answer.map(drop)) + } + /// Poll for the first verdict on a handle's token: a grant on any session, or the /// refusal or end that settles it. - fn poll_answered(&self, waiter: &kio::Waiter) -> Poll> { + fn poll_answer(&self, waiter: &kio::Waiter) -> Poll> { let id = self.presented.id; let answered = |state: &State| match state.tokens.get(&id) { Some(token) => token.answered.clone(), @@ -3170,7 +3173,7 @@ mod tests { let ietf = Link::ietf(); let auth = ietf.present(Bytes::from_static(b"jwt"), false).unwrap(); assert!(matches!( - auth.poll_answered(&kio::Waiter::noop()), + auth.poll_answer(&kio::Waiter::noop()), Poll::Ready(Ok(Answer::Requests)) )); assert_eq!(auth.grant().peek(), None); @@ -3183,7 +3186,7 @@ mod tests { let lite = Link::new(false); let auth = lite.present(Bytes::from_static(b"jwt"), false).unwrap(); assert!(matches!( - auth.poll_answered(&kio::Waiter::noop()), + auth.poll_answer(&kio::Waiter::noop()), Poll::Ready(Err(Error::Unsupported)) )); } @@ -3251,11 +3254,11 @@ mod tests { let handle = Handle::new(); let first = Link::attach(&handle, true, false).unwrap(); assert!(matches!(Link::attach(&handle, true, false), Err(Error::Duplicate))); - let auth = handle.present(Bytes::from_static(b"jwt")).unwrap(); + let auth = handle.add(Bytes::from_static(b"jwt")).unwrap(); let id = opened(&first); first.grant(id, Grant::all()); assert!(matches!( - auth.poll_answered(&kio::Waiter::noop()), + auth.poll_answer(&kio::Waiter::noop()), Poll::Ready(Ok(Answer::Granted)) )); assert_eq!(auth.grant().peek(), Some(Grant::all())); @@ -3289,20 +3292,20 @@ mod tests { fn the_answer_outlives_its_session() { let handle = Handle::new(); let session = Link::attach(&handle, true, false).unwrap(); - let auth = handle.present(Bytes::from_static(b"jwt")).unwrap(); + let auth = handle.add(Bytes::from_static(b"jwt")).unwrap(); session.grant(opened(&session), Grant::all()); session.close(Error::Cancel); assert_eq!(auth.grant().peek(), None); assert!(matches!( - auth.poll_answered(&kio::Waiter::noop()), + auth.poll_answer(&kio::Waiter::noop()), Poll::Ready(Ok(Answer::Granted)) )); let handle = Handle::new(); let _session = Link::attach(&handle, false, true).unwrap(); - let auth = handle.present(Bytes::from_static(b"jwt")).unwrap(); + let auth = handle.add(Bytes::from_static(b"jwt")).unwrap(); assert!(matches!( - auth.poll_answered(&kio::Waiter::noop()), + auth.poll_answer(&kio::Waiter::noop()), Poll::Ready(Ok(Answer::Requests)) )); } @@ -3318,7 +3321,7 @@ mod tests { assert_eq!(link.retry(id, &lost), None, "the application hears of a lost token"); link.ended(id, lost); assert!(matches!( - auth.poll_answered(&kio::Waiter::noop()), + auth.poll_answer(&kio::Waiter::noop()), Poll::Ready(Err(Error::Stream(_))) )); @@ -3342,12 +3345,12 @@ mod tests { draining.drain_with(std::sync::Arc::new(goaway)); let successor = Link::attach(&handle, true, false).unwrap(); - let auth = handle.present(Bytes::from_static(b"jwt")).unwrap(); + let auth = handle.add(Bytes::from_static(b"jwt")).unwrap(); assert!(Selector::Token(auth.id()).ended(&handle.state.read()).is_none()); let id = opened(&successor); successor.grant(id, Grant::all()); assert!(matches!( - auth.poll_answered(&kio::Waiter::noop()), + auth.poll_answer(&kio::Waiter::noop()), Poll::Ready(Ok(Answer::Granted)) )); } @@ -3358,7 +3361,7 @@ mod tests { fn a_draining_refusal_leaves_the_successors_grant() { let handle = Handle::new(); let draining = Link::attach(&handle, true, false).unwrap(); - let auth = handle.present(Bytes::from_static(b"jwt")).unwrap(); + let auth = handle.add(Bytes::from_static(b"jwt")).unwrap(); let id = opened(&draining); let (goaway, _protocol) = crate::goaway::Handle::new(false); goaway.producer().send(crate::goaway::Goaway::new()).unwrap(); @@ -3379,7 +3382,7 @@ mod tests { fn a_draining_refusal_defers_to_the_successors_retry() { let handle = Handle::new(); let draining = Link::attach(&handle, true, false).unwrap(); - let auth = handle.present(Bytes::from_static(b"jwt")).unwrap(); + let auth = handle.add(Bytes::from_static(b"jwt")).unwrap(); let id = opened(&draining); let (goaway, _protocol) = crate::goaway::Handle::new(false); goaway.producer().send(crate::goaway::Goaway::new()).unwrap(); @@ -3433,7 +3436,7 @@ mod tests { fn a_lost_stream_is_presented_again() { let handle = Handle::new(); let link = Link::attach(&handle, true, true).unwrap(); - let auth = handle.present(Bytes::from_static(b"jwt")).unwrap(); + let auth = handle.add(Bytes::from_static(b"jwt")).unwrap(); let id = opened(&link); let lost = Error::Stream(crate::StreamError::Internal); @@ -3469,7 +3472,7 @@ mod tests { fn a_draining_session_leaves_an_undecided_token() { let handle = Handle::new(); let draining = Link::attach(&handle, true, false).unwrap(); - let _auth = handle.present(Bytes::from_static(b"jwt")).unwrap(); + let _auth = handle.add(Bytes::from_static(b"jwt")).unwrap(); let id = opened(&draining); let (goaway, _protocol) = crate::goaway::Handle::new(false); goaway.producer().send(crate::goaway::Goaway::new()).unwrap(); @@ -3495,7 +3498,7 @@ mod tests { link.refused(id); assert_eq!(link.retry(id, &busy), Some(RETRY_INITIAL)); - assert!(auth.poll_answered(&kio::Waiter::noop()).is_pending()); + assert!(auth.poll_answer(&kio::Waiter::noop()).is_pending()); assert_eq!(link.reopen(id).unwrap().as_ref(), b"jwt"); link.refused(id); assert_eq!(link.retry(id, &busy), Some(RETRY_INITIAL * 2)); @@ -3504,7 +3507,7 @@ mod tests { link.reopen(id).unwrap(); link.grant(id, Grant::all()); assert!(matches!( - auth.poll_answered(&kio::Waiter::noop()), + auth.poll_answer(&kio::Waiter::noop()), Poll::Ready(Ok(Answer::Granted)) )); } diff --git a/rs/moq-net/src/ietf/session.rs b/rs/moq-net/src/ietf/session.rs index 1ff9a030f55..9f0a07cef7d 100644 --- a/rs/moq-net/src/ietf/session.rs +++ b/rs/moq-net/src/ietf/session.rs @@ -2379,7 +2379,8 @@ mod tests { let session = auth_session(); let (handle, log) = (&session.handle, &session.log); - let auth = kio::clock::timeout(std::time::Duration::from_secs(1), handle.handle().add("token")) + let auth = handle.handle().add("token").unwrap(); + kio::clock::timeout(std::time::Duration::from_secs(1), auth.answered()) .await .expect("resolved promptly") .expect("kept for requests"); diff --git a/rs/moq-net/src/lite/session.rs b/rs/moq-net/src/lite/session.rs index 49b5bd1dc01..7ea510b79bb 100644 --- a/rs/moq-net/src/lite/session.rs +++ b/rs/moq-net/src/lite/session.rs @@ -1074,7 +1074,7 @@ mod tests { }) .unwrap(); let mut driver = start.driver; - let token = handle.present(bytes::Bytes::from_static(b"token")).unwrap(); + let token = handle.add(bytes::Bytes::from_static(b"token")).unwrap(); let res = kio::clock::timeout( std::time::Duration::from_secs(5), diff --git a/rs/moq-net/src/model/origin.rs b/rs/moq-net/src/model/origin.rs index ef34fe00695..27bfcaae499 100644 --- a/rs/moq-net/src/model/origin.rs +++ b/rs/moq-net/src/model/origin.rs @@ -3193,14 +3193,22 @@ impl RouteState { self.announcement.route().is_some() } - /// Apply `update` to the options, re-advertising a visible route in place. + /// Apply `update` to the options, re-advertising a visible route in place. A failed + /// change keeps the options as they were, so a closed route holds nothing it was given. fn change(&self, update: impl FnOnce(&mut RouteOptions)) -> Result<(), Error> { let mut control = self.control.lock(); + let previous = control.options.clone(); update(&mut control.options); - if !self.announcement.is_live()? { - return Ok(()); + // One rollback for both failures: closed before the check, or between it and the update. + let result = match self.announcement.is_live() { + Ok(true) => self.announcement.update_if(control.info(), None, |_| true).map(drop), + Ok(false) => Ok(()), + Err(err) => Err(err), + }; + if result.is_err() { + control.options = previous; } - self.announcement.update_if(control.info(), None, |_| true).map(|_| ()) + result } } @@ -9951,6 +9959,28 @@ mod tests { } } + /// A change to a closed route fails and keeps nothing, so the route holds no + /// credential it was refused. + #[kio::test] + #[cfg(feature = "async")] + async fn a_closed_route_keeps_no_refused_change() { + let handle = crate::auth::Link::ietf(); + let token = handle.present(bytes::Bytes::from_static(b"token"), false).unwrap(); + let producer = origin(1).produce(); + let mut announced = producer.consume().routes(); + let route = producer.route("room", RouteOptions::hidden()).unwrap(); + route.announce().unwrap(); + announced.expect_next_active("room").await; + route.broadcast().close(); + announced.expect_next_ended("room").await; + + assert!(matches!(route.set_auth(Some(token.clone())), Err(Error::Closed))); + assert!(matches!(route.set_cost(7), Err(Error::Closed))); + let control = route.state.control.lock(); + assert!(control.options.auth.is_none()); + assert_eq!(control.options.cost, Cost::default()); + } + /// A replacement under another instance from a cluster peer at equal cost and chain /// length, whose hash loses, competes with the lingering old route: nothing restarts and /// requests stay on the old route until it is withdrawn, then consumers restart and a diff --git a/rs/moq-net/tests/integration/auth.rs b/rs/moq-net/tests/integration/auth.rs index 6ea8556d464..5064449acac 100644 --- a/rs/moq-net/tests/integration/auth.rs +++ b/rs/moq-net/tests/integration/auth.rs @@ -149,7 +149,7 @@ async fn moqt_22_renewing_a_cancelled_announce_keeps_the_session() { within(async { let (mut pair, client_origin, _server_origin) = connect_route_auth(MOQT_22).await; let mut tokens = serve(pair.requests.take().unwrap(), |_| Some(Grant::all())); - let first = pair.client.auth().add("first").await.expect("kept for requests"); + let first = added(&pair.client.auth(), "first").await.expect("kept for requests"); let broadcast = client_origin .route("room/x", origin::RouteOptions::default().with_auth(first)) .unwrap(); @@ -162,7 +162,7 @@ async fn moqt_22_renewing_a_cancelled_announce_keeps_the_session() { let (token, _again) = tokens.next().await.expect("offered again after the cancel"); assert_eq!(token, b"first"); kio::clock::sleep(Duration::from_secs(1)).await; - let second = pair.client.auth().add("second").await.expect("kept for requests"); + let second = added(&pair.client.auth(), "second").await.expect("kept for requests"); broadcast.set_auth(Some(second)).unwrap(); let (token, _second) = tokens.next().await.expect("announced again"); assert_eq!(token, b"second"); @@ -181,13 +181,13 @@ async fn moqt_22_a_cancelled_renewal_keeps_the_session() { let mut tokens = serve(pair.requests.take().unwrap(), |token| { (token == b"first").then(Grant::all) }); - let first = pair.client.auth().add("first").await.expect("kept for requests"); + let first = added(&pair.client.auth(), "first").await.expect("kept for requests"); let broadcast = client_origin .route("room/x", origin::RouteOptions::default().with_auth(first)) .unwrap(); let (_, first) = tokens.next().await.expect("the announce's token"); - let second = pair.client.auth().add("second").await.expect("kept for requests"); + let second = added(&pair.client.auth(), "second").await.expect("kept for requests"); broadcast.set_auth(Some(second)).unwrap(); kio::clock::sleep(Duration::from_secs(1)).await; drop(first); @@ -206,7 +206,7 @@ async fn moqt_22_dropping_the_old_grant_with_its_renewal_keeps_the_announce() { within(async { let (mut pair, client_origin, server_origin) = connect_route_auth(MOQT_22).await; let mut requests = pair.requests.take().unwrap(); - let first = pair.client.auth().add("first").await.expect("kept for requests"); + let first = added(&pair.client.auth(), "first").await.expect("kept for requests"); let broadcast = client_origin .route("room/x", origin::RouteOptions::default().with_auth(first)) .unwrap(); @@ -217,7 +217,7 @@ async fn moqt_22_dropping_the_old_grant_with_its_renewal_keeps_the_announce() { .accept(Grant::all()); wait_announced(&server_origin.consume(), "room/x", true).await; - let second = pair.client.auth().add("second").await.expect("kept for requests"); + let second = added(&pair.client.auth(), "second").await.expect("kept for requests"); broadcast.set_auth(Some(second)).unwrap(); let renewal = requests.next().await.expect("the renewal's token"); assert_eq!(renewal.token().as_ref(), b"second"); @@ -280,7 +280,7 @@ async fn a_route_auth_survives_a_reconnect(version: &'static str) { let server_origin = produce_origin(1); let mut first = connect_route_auth_on(version, &client_origin, &server_origin, Some(handle.clone())).await; let mut tokens = serve(first.requests.take().unwrap(), |_| Some(Grant::all())); - let token = handle.add("first").await.expect("kept for requests"); + let token = added(&handle, "first").await.expect("kept for requests"); let _route = client_origin .route("room/x", origin::RouteOptions::default().with_auth(token)) .unwrap(); @@ -307,7 +307,7 @@ async fn a_route_auth_renews_its_announce(version: &'static str) { let (mut pair, client_origin, server_origin) = connect_route_auth(version).await; let mut tokens = serve(pair.requests.take().unwrap(), |_| Some(Grant::all())); - let first = pair.client.auth().add("first").await.expect("kept for requests"); + let first = added(&pair.client.auth(), "first").await.expect("kept for requests"); let route = client_origin .route("room/x", origin::RouteOptions::default().with_auth(first)) .unwrap(); @@ -315,7 +315,7 @@ async fn a_route_auth_renews_its_announce(version: &'static str) { assert_eq!(token, b"first"); wait_announced(&server_origin.consume(), "room/x", true).await; - let second = pair.client.auth().add("second").await.expect("kept for requests"); + let second = added(&pair.client.auth(), "second").await.expect("kept for requests"); route.set_auth(Some(second)).unwrap(); let (token, _second) = tokens.next().await.expect("the renewal's token"); assert_eq!(token, b"second"); @@ -657,7 +657,7 @@ async fn an_unanswered_token_holds_the_check(version: &'static str) { let _setup = requests.next().await.unwrap().accept(grant(&["baz"], &[])); assert_eq!(granted(&pair.client).await, grant(&["baz"], &[])); let auth = pair.client.auth(); - let pending = kio::clock::sim::spawn(async move { auth.add("new").await }); + let pending = kio::clock::sim::spawn(async move { added(&auth, "new").await }); let unanswered = requests.next().await.unwrap(); let broadcast = publisher .route("foo/bar", moq_net::origin::RouteOptions::hidden()) @@ -703,7 +703,7 @@ async fn a_broadcast_withdrawn_while_its_token_waits_is_not_refused(version: &'s let _setup = requests.next().await.unwrap().accept(grant(&["baz"], &[])); assert_eq!(granted(&pair.client).await, grant(&["baz"], &[])); let auth = pair.client.auth(); - let pending = kio::clock::sim::spawn(async move { auth.add("new").await }); + let pending = kio::clock::sim::spawn(async move { added(&auth, "new").await }); let unanswered = requests.next().await.unwrap(); let withdrawn = publisher .route("foo/bar", moq_net::origin::RouteOptions::hidden()) @@ -752,7 +752,7 @@ async fn tokens_union_and_withdrawing_one_shrinks_it(version: &'static str) { }); let (_, _setup) = issued.next().await.unwrap(); - let t1 = pair.client.auth().add("t1").await.expect("t1 granted"); + let t1 = added(&pair.client.auth(), "t1").await.expect("t1 granted"); let (_, t1_issued) = issued.next().await.unwrap(); assert_eq!(t1.grant().peek(), Some(grant(&["b"], &[]))); assert_eq!(granted(&pair.client).await, grant(&["a", "b"], &[])); @@ -916,7 +916,7 @@ async fn an_update_replaces_one_tokens_grant(version: &'static str) { _ => None, }); let (_, _setup) = issued.next().await.unwrap(); - let t1 = pair.client.auth().add("t1").await.unwrap(); + let t1 = added(&pair.client.auth(), "t1").await.unwrap(); let (_, t1_issued) = issued.next().await.unwrap(); t1_issued.update(grant(&["c"], &["c"])); @@ -959,7 +959,7 @@ async fn a_revoked_grant_withdraws_and_can_be_restored(version: &'static str) { crate::support::announced(&publisher.consume(), "a/x").await; assert_eq!(pair.client_transport.close_reason(), None); - let _again = pair.client.auth().add("again").await.expect("re-authorized"); + let _again = added(&pair.client.auth(), "again").await.expect("re-authorized"); wait_announced(&served, "a/x", true).await; }) .await @@ -991,9 +991,9 @@ async fn a_refused_token_reports_the_code(version: &'static str) { } }); - let err = pair.client.auth().add("forged").await.expect_err("refused"); + let err = added(&pair.client.auth(), "forged").await.expect_err("refused"); assert!(matches!(err, Error::Session(SessionError::Unauthorized)), "{err:?}"); - let err = pair.client.auth().add("opaque").await.expect_err("refused"); + let err = added(&pair.client.auth(), "opaque").await.expect_err("refused"); assert!(matches!(err, Error::Unsupported), "{err:?}"); }) .await @@ -1082,7 +1082,7 @@ async fn dropping_the_requests_refuses_queued_tokens(version: &'static str) { let first = requests.next().await.expect("setup token"); drop(first.accept(Grant::default())); let auth = pair.client.auth(); - let pending = kio::clock::sim::spawn(async move { auth.add("queued").await.map(drop) }); + let pending = kio::clock::sim::spawn(async move { added(&auth, "queued").await.map(drop) }); // Give the second token time to reach the queue before dropping it. kio::clock::sleep(Duration::from_millis(50)).await; drop(requests); @@ -1150,7 +1150,7 @@ async fn a_reset_auth_stream_reports_unsupported(version: &'static str) { .await; granted(&pair.client).await; - let err = pair.client.auth().add("token").await.expect_err("no acceptor"); + let err = added(&pair.client.auth(), "token").await.expect_err("no acceptor"); assert!(matches!(err, Error::Unsupported), "{err:?}"); assert_eq!(pair.client_transport.close_reason(), None); }) @@ -1166,9 +1166,12 @@ async fn older_versions_have_no_grant(version: &'static str) { let (pair, _broadcast) = connect_announced(version).await; assert_eq!(pair.client.auth().grant().peek(), None); match version.starts_with("moq-lite") { - true => assert!(matches!(pair.client.auth().add("token").await, Err(Error::Unsupported))), + true => assert!(matches!( + added(&pair.client.auth(), "token").await, + Err(Error::Unsupported) + )), false => { - let auth = pair.client.auth().add("token").await.expect("kept for requests"); + let auth = added(&pair.client.auth(), "token").await.expect("kept for requests"); assert_eq!(auth.grant().peek(), None); assert_eq!(pair.client.auth().grant().peek(), None); } @@ -1275,7 +1278,7 @@ async fn a_live_subscription_survives_union_renewal(version: &'static str) { let mut requests = pair.requests.take().unwrap(); let _setup = requests.next().await.unwrap().accept(Grant::default()); let auth = pair.client.auth(); - let (first, first_grant) = futures::join!(auth.add("first"), async { + let (first, first_grant) = futures::join!(added(&auth, "first"), async { requests.next().await.unwrap().accept(grant(&[], &["room"])) }); let first = first.unwrap(); @@ -1298,7 +1301,7 @@ async fn a_live_subscription_survives_union_renewal(version: &'static str) { .await .unwrap(); subscription.recv_group().await.unwrap().unwrap(); - let (renewed, _renewed_grant) = futures::join!(auth.add("renewal"), async { + let (renewed, _renewed_grant) = futures::join!(added(&auth, "renewal"), async { requests.next().await.unwrap().accept(grant(&[], &["room"])) }); let _renewed = renewed.unwrap(); @@ -1508,7 +1511,7 @@ async fn a_grant_update_too_large_for_one_message_is_unsupported(version: &'stat Some(grant(if token.is_empty() { &["a"] } else { &["b"] }, &[])) }); let (_, _setup) = issued.next().await.unwrap(); - let token = pair.client.auth().add("update").await.unwrap(); + let token = added(&pair.client.auth(), "update").await.unwrap(); let (_, updated) = issued.next().await.unwrap(); assert_eq!(granted(&pair.client).await, grant(&["a", "b"], &[])); let huge: Patterns = (0..20) @@ -1558,7 +1561,7 @@ async fn pattern_grants_arrive_exactly(version: &'static str) { let mut held = Vec::new(); for token in ["exact", "wildcard", "mixed", "root"] { - let added = pair.client.auth().add(token).await.expect(token); + let added = added(&pair.client.auth(), token).await.expect(token); assert_eq!(added.grant().peek(), table(token.as_bytes()), "{token}"); held.push(added); } @@ -2126,9 +2129,9 @@ async fn a_widening_brings_back_what_the_peer_published(version: &'static str) { async fn a_shared_handle_presents_its_token_on_every_session(version: &'static str) { within(async { let handle = auth::Handle::new(); - let added = kio::clock::sim::spawn({ + let adding = kio::clock::sim::spawn({ let handle = handle.clone(); - async move { handle.add("token").await } + async move { added(&handle, "token").await } }); let options = || Options { version: Some(version), @@ -2143,7 +2146,7 @@ async fn a_shared_handle_presents_its_token_on_every_session(version: &'static s b"" => Some(Grant::default()), _ => Some(grant(&["first"], &[])), }); - let token = added.await.expect("joined").expect("granted on the first session"); + let token = adding.await.expect("joined").expect("granted on the first session"); let _first = presented(&mut tokens).await; wait_for(token.grant(), |g| g == &Some(grant(&["first"], &[]))).await; @@ -2200,9 +2203,9 @@ async fn a_handle_backs_one_active_session(version: &'static str) { }; let mut first = connect(options()).await; let mut first_requests = first.requests.take().unwrap(); - let added = kio::clock::sim::spawn({ + let adding = kio::clock::sim::spawn({ let handle = handle.clone(); - async move { handle.add("token").await } + async move { added(&handle, "token").await } }); let mut first_issued = Vec::new(); let unanswered = loop { @@ -2233,7 +2236,7 @@ async fn a_handle_backs_one_active_session(version: &'static str) { let mut second = connect(options()).await; let mut second_tokens = serve(second.requests.take().unwrap(), |_| Some(Grant::all())); let _second_issued = presented(&mut second_tokens).await; - let _token = added.await.expect("joined").expect("granted on the second session"); + let _token = adding.await.expect("joined").expect("granted on the second session"); kio::clock::sleep(Duration::from_secs(1)).await; assert!( @@ -2247,7 +2250,7 @@ async fn a_handle_backs_one_active_session(version: &'static str) { "the draining session dropped its late grant" ); - let later = handle.add("later").await.expect("granted on the second session"); + let later = added(&handle, "later").await.expect("granted on the second session"); let (token, _later) = second_tokens.next().await.expect("the active session presents it"); assert_eq!(token, b"later"); assert!( @@ -2280,7 +2283,7 @@ async fn a_token_queued_before_our_goaway_rides_only_the_successor(version: &'st assert!(setup.is_empty()); // Its first poll queues the token; the driver runs only once this side drains. - let mut adding = Box::pin(handle.add("late")); + let mut adding = Box::pin(added(&handle, "late")); assert!(adding.as_mut().now_or_never().is_none()); first.client.drain().send(moq_net::goaway::Goaway::new()).unwrap(); kio::clock::sleep(Duration::from_secs(1)).await; @@ -2327,7 +2330,7 @@ async fn a_transient_refusal_is_presented_again(version: &'static str) { } } }); - let token = pair.client.auth().add("token").await.expect("granted on the retry"); + let token = added(&pair.client.auth(), "token").await.expect("granted on the retry"); assert_eq!(token.grant().peek(), Some(Grant::default())); let _setup = issued.next().await; let _token = issued.next().await; @@ -2335,3 +2338,10 @@ async fn a_transient_refusal_is_presented_again(version: &'static str) { .await .expect("timed out"); } + +/// Present `token` on `handle` and wait for its first verdict. +async fn added(handle: &auth::Handle, token: impl Into) -> moq_net::Result { + let credential = handle.add(token)?; + credential.answered().await?; + Ok(credential) +} diff --git a/rs/moq-relay/benches/auth.rs b/rs/moq-relay/benches/auth.rs index 38ba51af198..4d6ac8c5f5b 100644 --- a/rs/moq-relay/benches/auth.rs +++ b/rs/moq-relay/benches/auth.rs @@ -82,10 +82,10 @@ impl Fixture { let connection = client.clone().connect(url.clone()).established().await.unwrap(); producers.push(receive.recv().await.unwrap().1); let auth = connection.auth(); - let added = futures::future::join_all((0..tokens).map(|index| auth.add(format!("token{index}")))).await; + let added = futures::future::join_all((0..tokens).map(|index| added(&auth, format!("token{index}")))).await; let added: Vec<_> = added.into_iter().map(|added| added.unwrap()).collect(); if session == 0 { - // `add` resolves once the peer grants the token, so its grant is already current. + // `added` resolves once the peer grants the token, so its grant is already current. let observed = added.last().unwrap().grant(); assert!(observed.peek().is_some()); watch = Some(observed); @@ -205,7 +205,7 @@ impl Queue { let auth = self.auth.clone(); let token = format!("{:0>width$}", self.presented, width = bytes); self.presented += 1; - tasks.push(tokio::spawn(async move { auth.add(token).await })); + tasks.push(tokio::spawn(async move { added(&auth, token).await })); } for _ in 0..offered { self.admissions @@ -247,3 +247,13 @@ fn queue(c: &mut Criterion) { criterion_group!(benches, refresh, queue); criterion_main!(benches); + +/// Present `token` on `handle` and wait for its first verdict. +async fn added( + handle: &moq_net::auth::Handle, + token: impl Into, +) -> moq_net::Result { + let credential = handle.add(token)?; + credential.answered().await?; + Ok(credential) +} diff --git a/rs/moq-relay/src/auth/refresh.rs b/rs/moq-relay/src/auth/refresh.rs index 9958157fed5..9589a96b0a4 100644 --- a/rs/moq-relay/src/auth/refresh.rs +++ b/rs/moq-relay/src/auth/refresh.rs @@ -528,6 +528,16 @@ mod tests { use futures::FutureExt; use std::time::Duration; + /// Present `token` on `handle` and wait for its first verdict. + async fn added( + handle: &moq_net::auth::Handle, + token: impl Into, + ) -> moq_net::Result { + let credential = handle.add(token)?; + credential.answered().await?; + Ok(credential) + } + fn grant(prefix: &str, seconds: u64) -> Grant { let mut grant = Grant::new(prefix.parse::().unwrap(), moq_auth::Patterns::new()); grant.expires = Some(kio::clock::wall() + Duration::from_secs(seconds)); @@ -759,7 +769,7 @@ mod tests { let _ = invalid.await; drop(retried); let auth = client.auth(); - let (result, ()) = tokio::join!(auth.add("valid"), async { + let (result, ()) = tokio::join!(added(&auth, "valid"), async { let admission = admissions.next().await.unwrap(); assert_eq!(admission.request.query.as_deref(), Some("jwt=valid")); admission.grant(lease::Consumer::fixed(grant("a/**", 3000))); @@ -775,7 +785,7 @@ mod tests { let (lease, mut admissions) = admission(grant("a/**", 3000)).await; let (client, supervising) = sessions(lease).await; let auth = client.auth(); - let (token, ()) = tokio::join!(auth.add("replacement"), async { + let (token, ()) = tokio::join!(added(&auth, "replacement"), async { let admission = admissions.next().await.unwrap(); assert!(admission.attached(), "an in-band token arrived as a new session"); admission.grant(lease::Consumer::fixed(grant("a/**", 3000))); @@ -832,14 +842,14 @@ mod tests { let (client, supervising) = sessions(lease).await; let auth = client.auth(); - let (higher, ()) = tokio::join!(auth.add("higher"), async { + let (higher, ()) = tokio::join!(added(&auth, "higher"), async { let higher = capped("a/**", Some(4_000), None); admissions.next().await.unwrap().grant(lease::Consumer::fixed(higher)); }); // Held, since dropping a token withdraws its credential. let _higher = higher.unwrap(); let (producer, uncapped) = lease::Producer::new(capped("a/**", None, None)); - let (lifted, ()) = tokio::join!(auth.add("uncapped"), async { + let (lifted, ()) = tokio::join!(added(&auth, "uncapped"), async { admissions.next().await.unwrap().grant(uncapped); }); let _lifted = lifted.unwrap(); @@ -864,7 +874,7 @@ mod tests { let (lease, mut admissions) = admission(grant("a/**", 3000)).await; let (client, supervising) = sessions(lease).await; let auth = client.auth(); - let (result, ()) = tokio::join!(auth.add("capped"), async { + let (result, ()) = tokio::join!(added(&auth, "capped"), async { let capped = capped("a/**", None, Some(8_000)); admissions.next().await.unwrap().grant(lease::Consumer::fixed(capped)); }); @@ -938,7 +948,7 @@ mod tests { }); let auth = client.auth(); - let (token, ()) = tokio::join!(auth.add("replacement"), async { + let (token, ()) = tokio::join!(added(&auth, "replacement"), async { admissions .next() .await @@ -996,7 +1006,7 @@ mod tests { /// Present `token` in the background, as a peer whose credit allows it. fn present(client: &moq_net::Session, token: &'static str) -> tokio::task::JoinHandle> { let auth = client.auth(); - tokio::spawn(async move { auth.add(token).await.map(drop) }) + tokio::spawn(async move { added(&auth, token).await.map(drop) }) } /// A token past the verification limit waits for a slot rather than being refused, @@ -1075,7 +1085,7 @@ mod tests { let (lease, mut admissions) = admission(grant("a/**", 3)).await; let (client, mut supervising) = sessions(lease).await; let auth = client.auth(); - let (token, ()) = tokio::join!(auth.add("replacement"), async { + let (token, ()) = tokio::join!(added(&auth, "replacement"), async { let admission = admissions.next().await.unwrap(); assert_eq!(admission.request.query.as_deref(), Some("jwt=replacement")); admission.grant(lease::Consumer::fixed(grant("a/**", 20))); @@ -1121,7 +1131,7 @@ mod tests { async { tokio::select! { reason = lease.ended() => panic!("the lease ended while renewing: {reason}"), - token = auth.add("replacement") => token.unwrap(), + token = added(&auth, "replacement") => token.unwrap(), } }, async { admissions.next().await.unwrap().grant(replacement) }, @@ -1192,7 +1202,7 @@ mod tests { let (client, supervising) = sessions(lease).await; let auth = client.auth(); for refusal in [0, 1, 2] { - let (result, ()) = tokio::join!(auth.add("bad"), async { + let (result, ()) = tokio::join!(added(&auth, "bad"), async { let admission = admissions.next().await.unwrap(); if refusal == 1 { let mut other = grant("b/**", 30); @@ -1219,10 +1229,10 @@ mod tests { let (lease, mut admissions) = admission(grant("a/**", 30)).await; let (client, supervising) = sessions(lease).await; let auth = client.auth(); - let mut added = grant("b/**", 30); - added.subscribe = ["**".parse().unwrap()].into_iter().collect(); - let (producer, consumer) = lease::Producer::new(added); - let (token, ()) = tokio::join!(auth.add("replacement"), async { + let mut wider = grant("b/**", 30); + wider.subscribe = ["**".parse().unwrap()].into_iter().collect(); + let (producer, consumer) = lease::Producer::new(wider); + let (token, ()) = tokio::join!(added(&auth, "replacement"), async { admissions.next().await.unwrap().grant(consumer); }); let token = token.unwrap(); @@ -1258,7 +1268,7 @@ mod tests { }); let (client, supervising) = sessions(lease.unwrap()).await; let auth = client.auth(); - let (token, ()) = tokio::join!(auth.add("replacement"), async { + let (token, ()) = tokio::join!(added(&auth, "replacement"), async { admissions .next() .await @@ -1270,7 +1280,7 @@ mod tests { moved.root = Some("other".into()); owner.update(moved); tokio::task::yield_now().await; - let result = auth.add("").await; + let result = added(&auth, "").await; assert!( matches!(result, Err(moq_net::Error::Session(SessionError::Unauthorized))), "an empty token reissued a credential the relay removed: {:?}", @@ -1341,7 +1351,7 @@ mod tests { .unwrap(); let (client, supervising) = sessions(lease).await; assert!(matches!( - client.auth().add("token").await, + added(&client.auth(), "token").await, Err(moq_net::Error::Unsupported) )); client.abort(moq_net::Error::Cancel); @@ -1369,7 +1379,7 @@ mod tests { }); let (client, supervising) = sessions(lease.unwrap()).await; assert!(matches!( - client.auth().add("replacement").await, + added(&client.auth(), "replacement").await, Err(moq_net::Error::Unsupported) )); assert!(admissions.next().now_or_never().is_none()); diff --git a/rs/moq-relay/tests/integration/auth_lifetime.rs b/rs/moq-relay/tests/integration/auth_lifetime.rs index 979d7f6201a..1b0e53e9aae 100644 --- a/rs/moq-relay/tests/integration/auth_lifetime.rs +++ b/rs/moq-relay/tests/integration/auth_lifetime.rs @@ -1959,10 +1959,8 @@ async fn in_band_tokens_widen_and_withdraw_live_prefixes() { let mut replacement = Grant::new(all(), "b/**".parse::().unwrap()); replacement.expires = Some(kio::clock::wall() + Duration::from_secs(3600)); script.on_connect(Answer::Grant(replacement)); - let token = kio::clock::timeout(TIMEOUT, subscriber.auth().add("replacement")) - .await - .unwrap() - .unwrap(); + let token = subscriber.auth().add("replacement").unwrap(); + kio::clock::timeout(TIMEOUT, token.answered()).await.unwrap().unwrap(); let b_read = kio::clock::timeout(TIMEOUT, read.follow("b/cam", None).unwrap().available()) .await .expect("new prefix not announced") @@ -2040,7 +2038,8 @@ async fn request_token_lease_ends_before_the_connection_and_reopens_later() { .established() .await .unwrap(); - let token = publisher.auth().add("shared").await.unwrap(); + let token = publisher.auth().add("shared").unwrap(); + token.answered().await.unwrap(); let first = origin .route( "first", diff --git a/rs/moq-tokio/src/connection.rs b/rs/moq-tokio/src/connection.rs index 3a7c7fdaa46..7bd7c4f9895 100644 --- a/rs/moq-tokio/src/connection.rs +++ b/rs/moq-tokio/src/connection.rs @@ -2274,7 +2274,10 @@ mod tests { let auth = connection.auth(); let added = tokio::spawn({ let auth = auth.clone(); - async move { auth.add("token").await } + async move { + let token = auth.add("token")?; + token.answered().await.map(|()| token) + } }); // Held past each session's abort: dropping a grant closes its stream cleanly, which diff --git a/swift/Sources/Moq/Auth.swift b/swift/Sources/Moq/Auth.swift index 40ace56ddfa..d9cfee01101 100644 --- a/swift/Sources/Moq/Auth.swift +++ b/swift/Sources/Moq/Auth.swift @@ -95,3 +95,31 @@ public final class Tokens: Sendable { try await ffi.answered() } } + +/// A request token a session presents, which a `Route` or `Claim` names with `setAuth`. +/// +/// Returned by `Session.credential(_:)`. It stays presented until `cancel()`, or until it +/// is released and no route or claim names it, answered or not. +public final class Credential: Sendable { + let ffi: MoqCredential + + init(_ ffi: MoqCredential) { + self.ffi = ffi + } + + /// Suspend until the peer's first verdict on the token: return once a peer accepts it, at + /// once on moq-transport, where only the requests that name it carry the token. + /// + /// Throws the peer's refusal as `MoqError.Protocol` carrying its code, + /// `MoqError.Unsupported` when nothing can carry a token (moq-lite without AUTH), and + /// `MoqError.Cancelled` once `cancel()` ran. + public func answered() async throws { + try await ffi.answered() + } + + /// Release the credential now: withdrawn at once unless a route or claim still names it, + /// and every `answered()` call throws `MoqError.Cancelled`. + public func cancel() { + ffi.cancel() + } +} diff --git a/swift/Sources/Moq/Client.swift b/swift/Sources/Moq/Client.swift index 69ee1ed63a7..733d3fb5d45 100644 --- a/swift/Sources/Moq/Client.swift +++ b/swift/Sources/Moq/Client.swift @@ -155,6 +155,17 @@ public final class Session: Sendable { GrantConsumer(ffi.grants()) } + /// Present a request token on this session, for a `Route` or `Claim` to carry with + /// `setAuth`. + /// + /// On moq-transport it rides each naming route's announcing request; on moq-lite it needs + /// AUTH, where it earns a grant. A client session presents it again on every reconnect, + /// and with no session live it waits for the next one. + /// - Throws: `MoqError.Config` for an empty token. + public func credential(_ token: String) throws -> Credential { + Credential(try ffi.credential(token: token)) + } + /// The credentials a native client session presents, to replace while it runs; /// `nil` for a server-accepted session, which presents none. public func tokens() -> Tokens? { diff --git a/swift/Sources/Moq/Origin.swift b/swift/Sources/Moq/Origin.swift index 2819be227f1..b071f9ad0cf 100644 --- a/swift/Sources/Moq/Origin.swift +++ b/swift/Sources/Moq/Origin.swift @@ -130,6 +130,14 @@ public final class Route: Sendable { try ffi.setCost(cost: cost) } + /// Swap the credential the route's announcing request carries, renewing that request in + /// place, or `nil` to announce under the session's scope. Only the session it was minted + /// on carries it. Throws `Closed` once its broadcast closed, keeping the credential it + /// had, and `Cancelled` for a cancelled credential. + public func setAuth(_ credential: Credential?) throws { + try ffi.setAuth(credential: credential?.ffi) + } + /// Retract the route now: later calls throw `Cancelled`. Its broadcast lives on until /// closed. public func cancel() { @@ -172,6 +180,13 @@ public final class Claim: AsyncSequence, Sendable { try ffi.setCost(cost: cost) } + /// Swap the credential the claim's announcing request carries, renewing that request in + /// place, or `nil` to announce under the session's scope. Only the session it was minted + /// on carries it. Throws `Cancelled` for a cancelled credential. + public func setAuth(_ credential: Credential?) throws { + try ffi.setAuth(credential: credential?.ffi) + } + /// The next broadcast requested beneath this claim, or `nil` once the claim is retracted /// or the origin closes. public func requested() async throws -> BroadcastRequest? { diff --git a/swift/Tests/MoqTests/SmokeTests.swift b/swift/Tests/MoqTests/SmokeTests.swift index 4c5f936527e..186df62468b 100644 --- a/swift/Tests/MoqTests/SmokeTests.swift +++ b/swift/Tests/MoqTests/SmokeTests.swift @@ -220,6 +220,57 @@ final class SmokeTests: XCTestCase { session.cancel(code: 0) } + /// A credential is answered by the peer, names a route and a claim, and cancels. + func testSessionCredentialLifecycle() async throws { + let version = ["moq-lite-07-wip"] + let server = try Server(bind: "127.0.0.1:0", versions: version, tls: ServerTls(generate: ["localhost"])) + defer { server.cancel() } + let addr = try await server.listen() + async let accepted = server.accept() + + let client = try Client(bind: "127.0.0.1:0", versions: version, tls: ClientTls(insecure: true), once: true) + defer { client.cancel() } + let session = try await client.connect(to: "https://\(addr)") + guard let request = try await accepted else { return XCTFail("server closed before accept") } + let peer = try await request.accept() + + XCTAssertThrowsError(try session.credential("")) { error in + guard case MoqError.Config = error else { + return XCTFail("expected Config, got \(error)") + } + } + let credential = try session.credential("extra") + do { + try await credential.answered() + XCTFail("the default acceptor refuses a token") + } catch MoqError.Unsupported {} + + let route = try session.publish.route(path: "room/x", hidden: true) + let claim = try session.publish.claim(prefix: "live", hidden: true) + for target in [route.setAuth, claim.setAuth] { + try target(credential) + try target(nil) + } + + credential.cancel() + do { + try await credential.answered() + XCTFail("a cancelled credential has no answer") + } catch MoqError.Cancelled {} + for target in [route.setAuth, claim.setAuth] { + XCTAssertThrowsError(try target(credential)) { error in + guard case MoqError.Cancelled = error else { + return XCTFail("expected Cancelled, got \(error)") + } + } + } + route.cancel() + claim.cancel() + + peer.cancel(code: 0) + session.cancel(code: 0) + } + /// Over moq-lite-06, whose wire carries no instance, the session mints one: passing it back /// pins `request` and `follow`, while its text parsed is another, named instance that nothing /// serves.