diff --git a/dart/moq_ffi/lib/src/moq.dart b/dart/moq_ffi/lib/src/moq.dart index 809e8b5e944..dfb499b46c3 100644 --- a/dart/moq_ffi/lib/src/moq.dart +++ b/dart/moq_ffi/lib/src/moq.dart @@ -20212,13 +20212,13 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_method_moqauth_grant() != 14588) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqauth_grant_changed() != 5195) { + if (uniffi_moq_ffi_checksum_method_moqauth_grant_changed() != 38858) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqauthtoken_cancel() != 59324) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqauthtoken_closed() != 31020) { + if (uniffi_moq_ffi_checksum_method_moqauthtoken_closed() != 40912) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqauthtoken_grant() != 31654) { diff --git a/doc/bin/relay/auth.md b/doc/bin/relay/auth.md index 53a74329014..87bfbb402b5 100644 --- a/doc/bin/relay/auth.md +++ b/doc/bin/relay/auth.md @@ -141,8 +141,8 @@ key can itself be **scoped** at generation (`--root`, `--publish`, On moq-lite-07-wip or moq-transport draft-17 and later with MoQ Auth negotiated, a client can present another JWT without reconnecting. Present its replacement before the current grant expires and keep the returned token handle alive. -The native `Connection::auth()` handle re-presents retained tokens after a -reconnect; the JavaScript session's `auth.add()` presents one for that session. +The auth handle a reconnecting connection owns (native `Connection::auth()`, +JavaScript `connection.auth`) presents its tokens again on every reconnect. Older versions refuse in-band tokens. Each accepted token adds its publish and subscribe patterns to the session's @@ -176,8 +176,8 @@ one that times out while waiting is refused without being sent. A sent check always runs to completion, so the connection's `end` stays its last request and `--limit-token` counts stay exact. On moq-lite, an auth server that cannot answer, by timeout or outage, refuses a token as a -transient error, and `Connection::auth()` presents it again on its reconnect -backoff. On moq-transport the refusal is final and the token ends. Retrying +transient error, and the client presents it again after a backoff of 1s +doubling to 30s. On moq-transport the refusal is final and the token ends. Retrying never extends an accepted grant, so a session whose last grant expires during an outage still closes. diff --git a/doc/concept/moq-lite.md b/doc/concept/moq-lite.md index df852c3d62b..29f60613400 100644 --- a/doc/concept/moq-lite.md +++ b/doc/concept/moq-lite.md @@ -223,8 +223,10 @@ everywhere. On moq-transport an added token is kept even without the extension, so an announcement can name it: it rides that `PUBLISH_NAMESPACE` as its authorization token. Naming a fresh token renews the announcement in place with `REQUEST_UPDATE` from draft 16, and re-announces it on drafts 14 and 15, so it -can outlive the token it started with. The token belongs to the session that added it, so after a -reconnect the application adds it again and re-announces. moq-lite has no +can outlive the token it started with. A token added to a reconnecting +connection's auth handle is presented on every session it makes, so the +announcement keeps it across a reconnect with no re-add or re-announce; one +added to a single session's handle ends with that session. moq-lite has no per-request token, so there an announcement rides the session's scope. ## Subscriptions diff --git a/doc/concept/standard.md b/doc/concept/standard.md index 24c56defda0..31ffb7a7638 100644 --- a/doc/concept/standard.md +++ b/doc/concept/standard.md @@ -32,7 +32,7 @@ subset drops. What a peer actually observes against this implementation: - **Compression.** Rust peers on draft 17 and later exchange compressed tracks when both offer the [Flate extension](/draft/moq-flate). Older peers receive one shared inflated copy. A publisher that seals the compression declaration makes that fallback unavailable: an incompatible peer is refused, preserving the sealed metadata. JavaScript does not offer the IETF extension. - **Timing.** A track whose `SUBSCRIBE_OK` declares no `TIMESCALE` is untimed, as is every track on drafts 14 through 16. A Rust reader that never subscribed takes the units from `TRACK_STATUS` on draft 18 and later. - **Strict SETUP and parameters.** Repeated unknown `SETUP` options, GREASE included, are accepted if well-formed; a repeated known option is rejected. On draft 17 and later, a `GROUP_ORDER` other than Ascending (1) or Descending (2) closes the session. Message parameters follow the negotiated draft's lists: drafts 14 and 15 ignore an unknown or misplaced parameter, draft 16 ignores one defined only for another message but closes the session on an unknown one, and draft 17 and later close it on both. Other malformed control input closes the session with the draft's code too, such as a server `SETUP` carrying `PATH` or `AUTHORITY`, a request cut short by FIN, or a second `GOAWAY` on one stream; a first `GOAWAY` on a request stream is legal. -- **One `SETUP` credential**, forwarded to the [auth server](/bin/relay/auth#the-contract) unverified. A second `SETUP` token closes the session. We advertise no token cache, so an alias reference in `SETUP` closes it with `PROTOCOL_VIOLATION`, one on a request with `UNKNOWN_AUTH_TOKEN_ALIAS`, and a registration on a request with `AUTH_TOKEN_CACHE_OVERFLOW`. Where the Rust side authorizes the peer or verifies tokens, a token on a `SUBSCRIBE` or `PUBLISH_NAMESPACE` is verified like an in-band session token and authorizes that one request alone, even where that authorization already covers it; it never widens the session. It is refused with `NOT_SUPPORTED` when nothing verifies it, when it is empty, or when its Token Type is anything but 0 (out of band). A side that does neither, JavaScript included, ignores the token. Rust refuses a request carrying more than one distinct token with `NOT_SUPPORTED`, since it verifies one; JavaScript, which verifies none, ignores them all. A token-authorized `SUBSCRIBE` still stays within the grant the peer gave us. A token-authorized `PUBLISH_NAMESPACE` needs a grant covering everything beneath the namespace, and the tracks we subscribe under it rely on that announce instead of our limit, ending with it. An update renews the token, except on a drafts 14 and 15 `PUBLISH_NAMESPACE`, which has no update, and a refused renewal ends only that request. Rust and JavaScript send the token a route names (`Route::auth`) on its `PUBLISH_NAMESPACE`, including the one drafts 14 and 15 answer a solicited `SUBSCRIBE_NAMESPACE` with, and swapping it renews the request with `REQUEST_UPDATE` from draft 16, or ends and re-sends it before; the inline `NAMESPACE` that answers a solicited `SUBSCRIBE_NAMESPACE` from draft 16 carries no token. The token belongs to the session that added it, so a reconnect means adding it again and re-announcing. A token on any other request is decoded and grants nothing, and a request repeating one is refused with `MALFORMED_AUTH_TOKEN`. On draft 17 and later, the negotiated [MoQ Auth extension](/concept/moq-lite#authorization) accepts additional session tokens in band. +- **One `SETUP` credential**, forwarded to the [auth server](/bin/relay/auth#the-contract) unverified. A second `SETUP` token closes the session. We advertise no token cache, so an alias reference in `SETUP` closes it with `PROTOCOL_VIOLATION`, one on a request with `UNKNOWN_AUTH_TOKEN_ALIAS`, and a registration on a request with `AUTH_TOKEN_CACHE_OVERFLOW`. Where the Rust side authorizes the peer or verifies tokens, a token on a `SUBSCRIBE` or `PUBLISH_NAMESPACE` is verified like an in-band session token and authorizes that one request alone, even where that authorization already covers it; it never widens the session. It is refused with `NOT_SUPPORTED` when nothing verifies it, when it is empty, or when its Token Type is anything but 0 (out of band). A side that does neither, JavaScript included, ignores the token. Rust refuses a request carrying more than one distinct token with `NOT_SUPPORTED`, since it verifies one; JavaScript, which verifies none, ignores them all. A token-authorized `SUBSCRIBE` still stays within the grant the peer gave us. A token-authorized `PUBLISH_NAMESPACE` needs a grant covering everything beneath the namespace, and the tracks we subscribe under it rely on that announce instead of our limit, ending with it. An update renews the token, except on a drafts 14 and 15 `PUBLISH_NAMESPACE`, which has no update, and a refused renewal ends only that request. Rust and JavaScript send the token a route names (`Route::auth`) on its `PUBLISH_NAMESPACE`, including the one drafts 14 and 15 answer a solicited `SUBSCRIBE_NAMESPACE` with, and swapping it renews the request with `REQUEST_UPDATE` from draft 16, or ends and re-sends it before; the inline `NAMESPACE` that answers a solicited `SUBSCRIBE_NAMESPACE` from draft 16 carries no token. A token added to a reconnecting connection's auth handle rides every session it makes, so the announcement keeps it across a reconnect; one added to a single session's handle ends with that session. A token on any other request is decoded and grants nothing, and a request repeating one is refused with `MALFORMED_AUTH_TOKEN`. On draft 17 and later, the negotiated [MoQ Auth extension](/concept/moq-lite#authorization) accepts additional session tokens in band. - **Pause delivery.** `FORWARD=0` on a `SUBSCRIBE` or its update drops the subscription, closing its data streams and holding nothing upstream, while the request and its slot stay. `FORWARD=1` subscribes again as a new `SUBSCRIBE` with the same filter would, so nothing carries across the pause: its `REQUEST_OK` names the live edge it joined at. A paused update may change only priority and max age. - **Refused, not fatal.** A legal request this stack does not serve is rejected on its own and the session stays up: the fetch forms above get `NOT_SUPPORTED`. Range Filters get `INVALID_FILTER` on draft 19 and later because the advertised filter limit is zero. On draft 21 and later, namespaces whose first tuple field is exactly `.` or `.session` get `DOES_NOT_EXIST` before application lookup; other dot prefixes still reach the application. Subscription updates accept priority, negotiated subscriber max age, and delivery timeouts; Rust also renews request tokens and judges `NEW_GROUP_REQUEST` as a join (see dynamic groups below). Other supported-but-unimplemented preferences end that subscription; a `TRACK_PROPERTY_FILTER` there closes the session with `PROTOCOL_VIOLATION`, since it belongs only to `SUBSCRIBE_TRACKS`. On draft 18 and later a namespace update may change its prefix; suffixes after the acknowledgement are relative to that new prefix. - **Objects and datagrams.** Only subgroup 0 is accepted: a stream for any other subgroup, whether its header gives the ID or takes it from the first Object ID, is stopped and the session stays up. Rust relays preserve Object IDs, gaps, object properties, FIRST\_OBJECT, and END\_OF\_GROUP status objects between moq-transport peers for subgroup 0, including across a moq-lite-07 hop between two relays that negotiate the [ietf](/draft/moq-ietf) extension. Datagrams retain their IDs, properties, priority, and status. Plain Rust readers and moq-lite peers see consecutive frames and normal datagram payloads; JavaScript supports only normal datagrams at Object ID 0. A malformed datagram closes the session; a relayed one the downstream draft cannot carry is dropped. A relayed Timestamp leaves in the units the downstream response declares, and is dropped where it declares none (drafts 14 through 16), as for a plain frame; an immutable one that would change or vanish is refused. On a timed track the frame holds each object's time, so a mutable Timestamp that only repeats the immutable one is not restated; a status object has no frame, so it keeps its Timestamp among its properties. Only property 0x10 is read as a Timestamp. Property 0x06 stays opaque and keeps its value regardless of timestamp units; it is dropped across the draft-19 boundary, or refused when immutable, because draft 19 assigns it to SUBGROUP\_DELIVERY\_TIMEOUT. From draft 15, a status object carrying properties is refused, and one relayed from draft 14 leaves without them. Draft 14 allows them, but an END\_OF\_TRACK carrying properties ends the subscription as unsupported, since a track end has nowhere to keep them. A `FETCH` carries the END\_OF\_GROUP object on drafts 14 and 15; later drafts have no FETCH status, so their response ends at the last frame. A group cannot resume across route changes by frame ordinal once Object IDs skip ahead of the ordinals on either route; recovery refuses it instead of splicing different objects. diff --git a/doc/lib/js/net.md b/doc/lib/js/net.md index 421fdb294de..111507cbbac 100644 --- a/doc/lib/js/net.md +++ b/doc/lib/js/net.md @@ -40,14 +40,14 @@ broadcast.announce(); - **Follow restarts.** `origin.request(path, { announced: true })` follows whichever publisher instance serves the path: its `active` changes to a fresh broadcast on a restart, clears on an end, and resolves again on the next start, so read it reactively and close the request when done. Instances are never stitched, so subscribe again to each new broadcast. Another route of the same epoch taking over (epochs travel only on `moq-lite-07-wip`) keeps `active` as is, and each track subscription resumes there from the first group it lacks. A refusal ends the request. Add `epoch` to pin one instance; a plain `request(path)` keeps its accepted instance, can resolve blindly, and ends once that instance stops serving. To list what is live, iterate `announced(scope)`. - **Discovery follows what you watch.** A `consume` origin asks the relay for announcements only under the prefixes its readers watch with `announced(scope)` or `request(path, { announced: true })`, stops asking about a prefix 2s after nothing at or beneath it is watched, and asks again on reconnect. A plain `request(path)` asks nothing and subscribes blind, but once it resolves through a discovered route it watches its path until closed, so it outlives the listing that found it. A relay that refuses an announce request turns that session's announced requests blind too. - **Connections share by URL and versions.** `new Connection({ url })` pools and reconnects with backoff, which the elements use. An ordered `versions` list, spelled like `moq-lite-06` as in Rust, applies to both WebTransport and WebSocket; equivalent lists share, while a different order or an omitted list gets its own pool entry. Supplying your own transport options or origin selects a private loop. Receive estimates are on demand: observe `connection.probe` reactively or subscribe while you need them; `peek()` alone opens no PROBE stream. -- **GOAWAY moves the session.** The connection dials the replacement at once while the old session keeps serving its groups, up to `goaway.handover` (default 10s, or the relay's deadline when that is sooner). New requests keep opening on the old session until the replacement's route outranks it, which is a restart without an epoch: an unpinned announced request follows the replacement, while a blind or epoch-pinned request stays on its accepted instance. With an epoch, which travels only when `moq-lite-07-wip` is offered, the replacement serves the same broadcast, so open subscriptions move across without missing a group. An empty GOAWAY redials the same URL. A redirect stays on the same host unless `goaway.redirect` is `"follow"`. +- **GOAWAY moves the session.** The connection dials the replacement at once while the old session keeps serving its groups, up to `goaway.handover` (default 10s, or the relay's deadline when that is sooner). New requests keep opening on the old session until the replacement's route outranks it, which is a restart without an epoch: an unpinned announced request follows the replacement, while a blind or epoch-pinned request stays on its accepted instance. With an epoch, which travels only when `moq-lite-07-wip` is offered, the replacement serves the same broadcast, so open subscriptions move across without missing a group. An empty GOAWAY redials the same URL. A redirect stays on the same host unless `goaway.redirect` is `"follow"`, which also presents the tokens on the connection's `auth` handle to the new host. - **One send estimate per connection.** `Bandwidth.Allocator` divides it by track priority, max-min fair within a tier. An idle track claims nothing. Publishers reserve against it so their targets sum to the estimate. - **List a broadcast's tracks.** `for await (const event of broadcast.tracks())` reports each track the broadcast lists (`added`, with its `Track.Info`), one `synced`, then `added` and `removed` as tracks come and go, until the broadcast ends; leaving the loop stops listing. Over a session it needs `moq-lite-07-wip`, syncs at once with what this side holds, and the peer's tracks follow as `added`. A track served only on request is never listed. See [discovery](/concept/moq-lite#discovery). - **One track per name.** Concurrent subscriptions share one request and producer. `createTrack` on a name with a request still queued answers it. The application owns sequence numbers: a track that replaces an ended one, created or requested, throws on an append that would reuse a sequence the name already wrote, so it writes its first group with an explicit sequence. Only a new broadcast starts over. - **Timing is per track, with no default.** A track that declares a `timescale` carries a `timestamp` on every frame; one without is [untimed](/concept/moq-lite#subscriptions) and its frames carry none. Omitting it means untimed, as in Rust. Nothing stamps a frame for you: the publisher passes the timestamp, and the `Timed` type (`{ value, at }`) carries one into the `@moq/json` and `@moq/flate` producers. - **Subscriber staleness is `maxDelay`.** It counts from a group's successor, by media time and by wall clock, whichever reaches it first. Passing the old `maxAge` key throws a `TypeError` naming `maxDelay`. Publisher retention is the separate `Track.Info.maxAge`. - **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) and on moq-transport draft-17+ with the [MoQ Auth extension](/draft/moq-auth), `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)` keeps the token even without the extension, and a broadcast announced with the `Auth.Auth` it returns (`broadcast.announce({ auth })`) carries it on that announcement's `PUBLISH_NAMESPACE`; announcing again with another renews the request. An `Auth.Auth` belongs to the session that added it, so after a reconnect, add the token again and re-announce. +- **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) and on moq-transport draft-17+ with the [MoQ Auth extension](/draft/moq-auth), `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)` keeps the token even without the extension, and a broadcast announced with the `Auth.Auth` it returns (`broadcast.announce({ auth })`) carries it on that announcement's `PUBLISH_NAMESPACE`; announcing again with another 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. Application delivery requires explicit `moq-lite-07-wip`; older Lite versions wait only for transport ACKs. IETF media streams are not drained. `abort()` ends immediately. Examples: diff --git a/doc/lib/rs/moq-net.md b/doc/lib/rs/moq-net.md index 600abb11c1e..e77a7905714 100644 --- a/doc/lib/rs/moq-net.md +++ b/doc/lib/rs/moq-net.md @@ -26,7 +26,7 @@ What you use it for, beyond what the concept page already describes: - **Use the model without a runtime.** Disable default features on `moq-net` to use its `poll_*` APIs and origin driver with caller-supplied time. The default `async` feature adds async helpers, `time::run`, and both wire sessions. - **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 or in the browser. `moq-tokio` and `moq-wasm` hide 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. - **A session bounds what its peer can hold.** Each moq-lite request, and each moq-transport request from draft 17 on, holds a stream, so the transport's stream credit bounds subscriptions and FETCHes. `session::Limits` (via `Client::with_limits` and `Server::with_limits`) bounds the rest per session: `announces`, and `requests`, which with `announces` sizes the request-ID window on moq-transport drafts 14 to 16. The defaults suit a relay mesh, so lower them for untrusted peers. Past a limit, the session closes with `TOO_MANY_REQUESTS`; very low limits can starve the window, which every request counts against. Peer-declared lengths are capped before they are buffered. -- **Authorization is in band.** On moq-lite 07 (`moq-lite-07-wip`, opt-in) and on moq-transport draft-17+ with the [MoQ Auth extension](/draft/moq-auth), `session.auth()` presents tokens without reconnecting and watches the grant the peer sent back. 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) and on moq-transport draft-17+ with the [MoQ Auth extension](/draft/moq-auth), `session.auth()` presents tokens without reconnecting and watches the grant the peer sent back. `add(token)` resolves with the token and how the peer took it: `Answer::Granted` on its AUTH stream, or `Answer::Requests` 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. - **A graceful close waits.** `session.close().await` withdraws announcements and drains for up to one second. Application delivery requires explicit `moq-lite-07-wip`; older Lite versions (including default lite-06) and IETF wait only for transport ACKs. `abort` ends immediately. IETF drafts 14 through 16 send withdrawals without waiting. ```bash diff --git a/go/wrapper/session.go b/go/wrapper/session.go index 2af7b7246df..b631459a981 100644 --- a/go/wrapper/session.go +++ b/go/wrapper/session.go @@ -93,8 +93,8 @@ func (a *Auth) Grant() *Grant { } // GrantChanged blocks until the grant differs from the one this handle last -// reported, or held when it was made, and returns it. It errors once the session -// ends. +// reported, or held when it was made, and returns it. It errors once a client's +// connection or a server's session ends; a reconnect is not an end. func (a *Auth) GrantChanged(ctx context.Context) (*Grant, error) { grant, err := bridge.Call(ctx, nil, a.inner.GrantChanged) return grantFromFFI(grant), err @@ -125,8 +125,9 @@ func (t *AuthToken) Grant() *Grant { return grantFromFFI(t.inner.Grant()) } -// Closed blocks until the token ends, returning the peer's revocation or the -// session's close, or nil when the peer closed it without an error. +// Closed blocks until the token ends, returning the peer's revocation or the end +// of a client's connection or a server's session, or nil when the peer closed it +// without an error. A reconnect does not end it. func (t *AuthToken) Closed(ctx context.Context) error { return bridge.CallErr(ctx, nil, t.inner.Closed) } diff --git a/js/net/bench/forward.ts b/js/net/bench/forward.ts index ba8e2a6fb4a..f174a35548a 100644 --- a/js/net/bench/forward.ts +++ b/js/net/bench/forward.ts @@ -7,6 +7,7 @@ import { Manual } from "@moq/clock"; import { Once } from "@moq/signals"; import { announcedView } from "../src/announced.test-util"; import * as Announce from "../src/announced.ts"; +import { AuthSession } from "../src/auth_session.ts"; import { Producer as BroadcastProducer } from "../src/broadcast.ts"; import type { Established } from "../src/connection/established.ts"; import { forwardAnnounced } from "../src/connection/forward.ts"; @@ -32,7 +33,11 @@ class FakeSession { die!: () => void; constructor() { - registerWire(this, { consume: () => new BroadcastProducer().consume(), goaway: new Once() }); + registerWire(this, { + consume: () => new BroadcastProducer().consume(), + goaway: new Once(), + auth: new AuthSession({ peerGrant: { publish: new Path.Patterns(), subscribe: new Path.Patterns() } }), + }); this.closed = new Promise((resolve) => { this.die = () => resolve(null); }); diff --git a/js/net/src/auth.test.ts b/js/net/src/auth.test.ts index 63fd363dcf7..96ab771a01b 100644 --- a/js/net/src/auth.test.ts +++ b/js/net/src/auth.test.ts @@ -1,8 +1,7 @@ import { describe, expect, spyOn, test } from "bun:test"; import { Manual, Milli, Nano } from "@moq/clock"; import type { Getter } from "@moq/signals"; -import { type Grant, type Issued, Unsupported } from "./auth.ts"; -import type { AuthSession } from "./auth_session.ts"; +import { Duplicate, type Grant, Handle, type Issued, type Request, type Requests, Unsupported } from "./auth.ts"; import { accept as acceptSession, connect as connectSession, type Established } from "./connection/index.ts"; import { SessionCode, SessionError, StreamCode, StreamError, toStreamCode } from "./error.ts"; import { AuthMessage as IetfAuthMessage, IetfAuthWire } from "./ietf/auth.ts"; @@ -16,7 +15,7 @@ import * as LiteMessage from "./lite/message.ts"; import { createMockTransportPair, type MockTransport } from "./mock.ts"; import { Producer as OriginProducer } from "./origin.ts"; import * as Path from "./path.ts"; -import { type Stream, Writer } from "./stream.ts"; +import { Stream, Writer } from "./stream.ts"; import { abandoned } from "./util/demand.ts"; import { withTimeout } from "./util/timeout.ts"; import { nextTask } from "./util/yield.ts"; @@ -67,17 +66,93 @@ interface Pair { transport: MockTransport; } -async function connect(opts: { publish?: OriginProducer; serverPublish?: OriginProducer; protocol: string }) { +async function connect(opts: { + publish?: OriginProducer; + serverPublish?: OriginProducer; + protocol: string; + auth?: Handle; +}) { const pair = createMockTransportPair(opts.protocol); const [client, server] = await Promise.all([ - connectSession({ url, transport: pair.client, publish: opts.publish?.consume() }), + connectSession({ url, transport: pair.client, publish: opts.publish?.consume(), auth: opts.auth }), acceptSession({ transport: pair.server, url, publish: opts.serverPublish?.consume() }), ]); return { client, server, transport: pair.client } satisfies Pair; } +/** Answer every token the peer presents with `answer`'s grant, holding each until the session ends. */ +function answer(requests: Requests, answer: (token: string) => Grant): Issued[] { + const issued: Issued[] = []; + void (async () => { + for (;;) { + const request = await requests.next(); + if (!request) break; + issued.push(request.accept(answer(new TextDecoder().decode(request.token)))); + } + })(); + return issued; +} + // Every case runs on moq-lite-07-wip and on moq-transport with the MoQ Auth extension. describe.each([Lite.ALPN_07_WIP, Ietf.ALPN.DRAFT_17, Ietf.ALPN.DRAFT_22])("%s", (protocol) => { + test("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 first = await connect({ publish: new OriginProducer(), protocol, auth: handle }); + answer(first.server.auth.requests(), (token) => (token ? grant(["first"], []) : grant([], []))); + const token = await adding; + await waitFor(token.grant, (g) => g?.publish.equals(patterns("first")) === true); + expect(first.client.auth).toBe(handle); + + first.client.abort(); + first.server.abort(); + expect(token.grant.peek()).toBeUndefined(); + expect(handle.grant.peek()).toBeUndefined(); + let ended = false; + void token.closed.then(() => { + ended = true; + }); + await nextTask(); + expect(ended).toBe(false); + + const second = await connect({ publish: new OriginProducer(), protocol, auth: handle }); + answer(second.server.auth.requests(), (token) => (token ? grant(["second"], []) : grant([], []))); + await waitFor(token.grant, (g) => g?.publish.equals(patterns("second")) === true); + await waitFor(handle.grant, (g) => g?.publish.equals(patterns("second")) === true); + expect(ended).toBe(false); + // A route naming the token carries it on the new session's requests, on moq-transport. + const carried = wireOf(second.client).auth.carried(token); + if (protocol === Lite.ALPN_07_WIP) expect(carried).toBeUndefined(); + else expect(carried).toEqual(new TextEncoder().encode("token")); + + token.close(); + expect(await token.closed).toBeNull(); + await waitFor(handle.grant, (g) => g?.publish.size === 0); + second.client.abort(); + second.server.abort(); + }); + + test("a handle backs one active session", async () => { + const handle = new Handle(); + const first = await connect({ publish: new OriginProducer(), protocol, auth: handle }); + const pair = createMockTransportPair(protocol); + await expect(connectSession({ url, transport: pair.client, auth: handle })).rejects.toBeInstanceOf(Duplicate); + pair.client.close(); + + first.client.abort(); + first.server.abort(); + const second = await connect({ publish: new OriginProducer(), protocol, auth: handle }); + expect(second.client.auth).toBe(handle); + + // 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"); + second.client.abort(); + second.server.abort(); + }); + test("both sides learn their default grant", async () => { const { client, server } = await connect({ publish: new OriginProducer(), protocol }); @@ -279,11 +354,13 @@ describe.each([Lite.ALPN_07_WIP, Ietf.ALPN.DRAFT_17, Ietf.ALPN.DRAFT_22])("%s", // Checked before any stream notices the transport closing. client.abort(); - const closed = client.auth.grant.peek(); + // No session is live, and every token ended with it. + expect(client.auth.grant.peek()).toBeUndefined(); + const closed = wireOf(client).auth.grant.peek(); expect(closed?.publish.size).toBe(0); expect(closed?.subscribe.size).toBe(0); // What the session enforced is not revoked by its end. - expect((client.auth as AuthSession).scope.peek()?.publish.size).toBeGreaterThan(0); + expect(wireOf(client).auth.scope.peek()?.publish.size).toBeGreaterThan(0); server.abort(); }); @@ -298,7 +375,7 @@ describe.each([Lite.ALPN_07_WIP, Ietf.ALPN.DRAFT_17, Ietf.ALPN.DRAFT_22])("%s", published.appendGroup().writeFrame({ payload: new Uint8Array([1]) }); const { client, server } = await connect({ serverPublish: origin, protocol }); const requests = server.auth.requests(); - await (client.auth as AuthSession).ready(Path.from("room/x"), "subscribe"); + await wireOf(client).auth.ready(Path.from("room/x"), "subscribe"); const setup = await requests.next(); if (!setup) throw new Error("missing setup token"); const initial = setup.accept(grant([], [])); @@ -361,7 +438,7 @@ describe.each([Lite.ALPN_07_WIP, Ietf.ALPN.DRAFT_17, Ietf.ALPN.DRAFT_22])("%s", if (!unanswered) throw new Error("missing token"); const track = new OriginProducer().createBroadcast(Path.from("room/x")).createTrack("video"); const reader = track.subscribe(); - const ready = (client.auth as AuthSession).ready(Path.from("room/x"), "subscribe", { + const ready = wireOf(client).auth.ready(Path.from("room/x"), "subscribe", { until: () => abandoned(track.demand()), }); let settled = false; @@ -472,8 +549,8 @@ describe.each([Lite.ALPN_07_WIP, Ietf.ALPN.DRAFT_17, Ietf.ALPN.DRAFT_22])("%s", // The grant drops as the session closes, which can land before the transport's close // reaches the subscription; force that order. - (client.auth as AuthSession).close(); - expect(client.auth.grant.peek()?.subscribe.size).toBe(0); + wireOf(client).auth.close(); + expect(wireOf(client).auth.grant.peek()?.subscribe.size).toBe(0); await nextTask(); expect(track.closed.peek()).toBeUndefined(); @@ -525,6 +602,320 @@ describe.each([Lite.ALPN_07_WIP, Ietf.ALPN.DRAFT_17, Ietf.ALPN.DRAFT_22])("%s", }); }); +// Close ends every token here unanswered, so nothing waiting on the setup token stays parked. +test("closing a session settles its unanswered setup token", async () => { + const { client, server } = await connect({ publish: new OriginProducer(), protocol: Lite.ALPN_07_WIP }); + const requests = server.auth.requests(); + try { + await requests.next(); + let settled = false; + void wireOf(client) + .auth.setupAnswered() + .then(() => { + settled = true; + }); + for (let i = 0; i < 10; i++) await nextTask(); + expect(settled).toBe(false); + client.abort(); + await settles(() => settled); + } finally { + client.abort(); + server.abort(); + } +}); + +// moq-transport's AUTH_ERROR carries no code that may pass, so only lite retries. +test("lite-07 presents a transiently refused token again after a backoff", async () => { + const clock = new Manual(); + const installed = clock.install(); + const warned = spyOn(console, "warn").mockImplementation(() => {}); + const refusals = () => warned.mock.calls.filter(([line]) => line === "auth token refused").length; + const { client, server } = await connect({ publish: new OriginProducer(), protocol: Lite.ALPN_07_WIP }); + const requests = server.auth.requests(); + const issued: Issued[] = []; + let added = false; + + /** Refuse `request` as busy, once the client has started its backoff. */ + const refuse = async (request: Request | undefined) => { + expect(request && new TextDecoder().decode(request.token)).toBe("token"); + const before = refusals(); + request?.reject(SessionCode.TooManyRequests, "busy"); + // The client starts its backoff as it logs the refusal. + await settles(() => refusals() > before); + }; + + /** The token presented again, exactly once `backoff` passes. */ + const after = async (backoff: Milli) => { + let presented = false; + const next = requests.next().then((request) => { + presented = true; + return request; + }); + await clock.advance(Nano.fromMilli(Milli(backoff - 1))); + for (let i = 0; i < 10; i++) await nextTask(); + expect(presented).toBe(false); + expect(added).toBe(false); + await clock.advance(Nano.fromMilli(Milli(1))); + return await next; + }; + + try { + const setup = await requests.next(); + if (!setup) throw new Error("missing setup token"); + issued.push(setup.accept(grant([], []))); + + const adding = client.auth.add("token"); + void adding.then(() => { + added = true; + }); + await refuse(await requests.next()); + // Unanswered through the backoff, so a publication it might cover waits for the retry. + let ready: boolean | undefined; + void wireOf(client) + .auth.ready(Path.from("a"), "publish") + .then((value) => { + ready = value; + }); + await refuse(await after(Milli(1000))); + expect(ready).toBeUndefined(); + const accepted = await after(Milli(2000)); + if (!accepted) throw new Error("the token was not presented again"); + issued.push(accepted.accept(grant(["a"], []))); + const token = await adding; + expect(token.grant.peek()?.publish.equals(patterns("a"))).toBe(true); + await settles(() => ready !== undefined); + expect(ready).toBe(true); + } finally { + client.abort(); + server.abort(); + warned.mockRestore(); + installed.close(); + } +}); + +// The peer replied to the first presentation, so it knows AUTH: a reset is a lost stream. +test("lite-07 keeps a shared token whose retry stream is reset", async () => { + const clock = new Manual(); + const installed = clock.install(); + const warned = spyOn(console, "warn").mockImplementation(() => {}); + const logged = (line: string) => warned.mock.calls.filter(([first]) => first === line).length; + const handle = new Handle(); + const { client, server } = await connect({ + publish: new OriginProducer(), + protocol: Lite.ALPN_07_WIP, + auth: handle, + }); + const requests = server.auth.requests(); + let read: ReturnType | undefined; + try { + const setup = await requests.next(); + setup?.accept(grant([], [])); + + let rejected: unknown; + handle.add("token").catch((err) => { + rejected = err; + }); + const request = await requests.next(); + request?.reject(SessionCode.TooManyRequests, "busy"); + await settles(() => logged("auth token refused") > 0); + + read = spyOn(LiteAuthWire.prototype, "read").mockRejectedValue( + new StreamError(StreamCode.Cancel, { message: "reset" }), + ); + await clock.advance(Nano.fromMilli(Milli(1000))); + await settles(() => logged("auth token ended") > 0); + for (let i = 0; i < 10; i++) await nextTask(); + expect(rejected).toBeUndefined(); + } finally { + read?.mockRestore(); + client.abort(); + server.abort(); + warned.mockRestore(); + installed.close(); + } +}); + +test("lite-07 presents a token added after a GOAWAY only on the successor", async () => { + const protocol = Lite.ALPN_07_WIP; + const handle = new Handle(); + const pair = createMockTransportPair(protocol); + const [client, server] = await Promise.all([ + connectSession({ url, transport: pair.client, publish: new OriginProducer().consume(), auth: handle }), + acceptSession({ transport: pair.server, url }), + ]); + const seen: string[] = []; + answer(server.auth.requests(), (token) => { + seen.push(token); + return token ? grant(["first"], []) : grant([], []); + }); + const token = await 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 }); + await stream.writer.u53(Lite.StreamId.Goaway); + await new Lite.Goaway("").encode(stream.writer, Lite.Version.DRAFT_07); + await settles(() => !wireOf(client).auth.active); + + const late = 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(); + expect(token.grant.peek()).toBeUndefined(); + + answer(successor.server.auth.requests(), (token) => (token ? grant(["second"], []) : grant([], []))); + await late; + await waitFor(handle.grant, (g) => g?.publish.equals(patterns("second")) === true); + for (let i = 0; i < 10; i++) await nextTask(); + expect(seen).not.toContain("late"); + + client.abort(); + server.abort(); + successor.client.abort(); + successor.server.abort(); +}); + +test("lite-07 refuses a token added to a session's own handle after a GOAWAY", async () => { + const protocol = Lite.ALPN_07_WIP; + const pair = createMockTransportPair(protocol); + const [client, server] = await Promise.all([ + connectSession({ url, transport: pair.client, publish: new OriginProducer().consume() }), + acceptSession({ transport: pair.server, url }), + ]); + const stream = await Stream.open(pair.server, { version: Lite.Version.DRAFT_07 }); + await stream.writer.u53(Lite.StreamId.Goaway); + await new Lite.Goaway("").encode(stream.writer, Lite.Version.DRAFT_07); + await settles(() => !wireOf(client).auth.active); + + try { + const late = withTimeout(client.auth.add("late"), Nano.fromMilli(Milli(1000)), "add after GOAWAY hung"); + await expect(late).rejects.toBeInstanceOf(StreamError); + } finally { + client.abort(); + server.abort(); + } +}); + +test("lite-07 ignores a draining session's late refusal once its successor granted", async () => { + const protocol = Lite.ALPN_07_WIP; + const handle = new Handle(); + const pair = createMockTransportPair(protocol); + const [client, server] = await Promise.all([ + connectSession({ url, transport: pair.client, publish: new OriginProducer().consume(), auth: handle }), + acceptSession({ transport: pair.server, url }), + ]); + const requests = server.auth.requests(); + const setup = await requests.next(); + if (!setup) throw new Error("missing setup token"); + const issued = [setup.accept(grant([], []))]; + const adding = handle.add("token"); + const unanswered = await requests.next(); + expect(unanswered && new TextDecoder().decode(unanswered.token)).toBe("token"); + + const stream = await Stream.open(pair.server, { version: Lite.Version.DRAFT_07 }); + await stream.writer.u53(Lite.StreamId.Goaway); + await new Lite.Goaway("").encode(stream.writer, Lite.Version.DRAFT_07); + await settles(() => !wireOf(client).auth.active); + + const successor = await connect({ publish: new OriginProducer(), protocol, auth: handle }); + answer(successor.server.auth.requests(), (token) => (token ? grant(["second"], []) : grant([], []))); + const token = await adding; + await waitFor(token.grant, (g) => g?.publish.equals(patterns("second")) === true); + + const warned = spyOn(console, "warn").mockImplementation(() => {}); + let ended = false; + void token.closed.then(() => { + ended = true; + }); + unanswered?.reject(SessionCode.Unauthorized, "stale"); + try { + await settles(() => warned.mock.calls.some(([line]) => line === "auth token refused")); + for (let i = 0; i < 10; i++) await nextTask(); + expect(ended).toBe(false); + expect(token.grant.peek()?.publish.equals(patterns("second"))).toBe(true); + } finally { + warned.mockRestore(); + for (const grant of issued) grant.close(); + client.abort(); + server.abort(); + successor.client.abort(); + successor.server.abort(); + } +}); + +test("lite-07 ignores a draining session's late refusal while its successor retries", async () => { + const clock = new Manual(); + const installed = clock.install(); + const warned = spyOn(console, "warn").mockImplementation(() => {}); + const refusals = () => warned.mock.calls.filter(([line]) => line === "auth token refused").length; + const protocol = Lite.ALPN_07_WIP; + const handle = new Handle(); + const pair = createMockTransportPair(protocol); + const [client, server] = await Promise.all([ + connectSession({ url, transport: pair.client, publish: new OriginProducer().consume(), auth: handle }), + acceptSession({ transport: pair.server, url }), + ]); + const requests = server.auth.requests(); + const issued: Issued[] = []; + const setup = await requests.next(); + if (!setup) throw new Error("missing setup token"); + issued.push(setup.accept(grant([], []))); + const adding = handle.add("token"); + const unanswered = await requests.next(); + expect(unanswered && new TextDecoder().decode(unanswered.token)).toBe("token"); + + const stream = await Stream.open(pair.server, { version: Lite.Version.DRAFT_07 }); + await stream.writer.u53(Lite.StreamId.Goaway); + await new Lite.Goaway("").encode(stream.writer, Lite.Version.DRAFT_07); + await settles(() => !wireOf(client).auth.active); + + const next = await connect({ publish: new OriginProducer(), protocol, auth: handle }); + const successorRequests = next.server.auth.requests(); + try { + const successorSetup = await successorRequests.next(); + if (!successorSetup) throw new Error("missing successor setup token"); + issued.push(successorSetup.accept(grant([], []))); + const first = await successorRequests.next(); + if (!first) throw new Error("the successor did not present the token"); + const firstGrant = first.accept(grant(["second"], [])); + const token = await adding; + await waitFor(token.grant, (g) => g?.publish.equals(patterns("second")) === true); + let ended = false; + void token.closed.then(() => { + ended = true; + }); + + // The successor's grant ends with a refusal that may pass, so it backs off with no grant. + const before = refusals(); + firstGrant.revoke(SessionCode.TooManyRequests, "busy"); + await settles(() => refusals() > before); + await waitFor(token.grant, (g) => g === undefined); + + // The draining session's stale refusal lands during that backoff. + const stale = refusals(); + unanswered?.reject(SessionCode.Unauthorized, "stale"); + await settles(() => refusals() > stale); + for (let i = 0; i < 10; i++) await nextTask(); + expect(ended).toBe(false); + + const retried = successorRequests.next(); + await clock.advance(Nano.fromMilli(Milli(1000))); + const again = await retried; + if (!again) throw new Error("the successor did not present the token again"); + issued.push(again.accept(grant(["third"], []))); + await waitFor(token.grant, (g) => g?.publish.equals(patterns("third")) === true); + expect(ended).toBe(false); + } finally { + for (const grant of issued) grant.close(); + client.abort(); + server.abort(); + next.client.abort(); + next.server.abort(); + warned.mockRestore(); + installed.close(); + } +}); + +// moq-lite carries patterns, so every grant arrives exactly as issued. test("a fetch abandoned while its token waits never reaches the wire", async () => { const logged = spyOn(console, "debug"); const origin = new OriginProducer(); @@ -699,7 +1090,7 @@ test.each([ const bytes = new TextEncoder().encode("bytes"); const copied = await client.auth.add(bytes); bytes.fill(0); - expect((client.auth as AuthSession).carried(copied)).toEqual(new TextEncoder().encode("bytes")); + 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); @@ -727,6 +1118,39 @@ test("a reset AUTH stream fails the token on moq-transport", async () => { } }); +test("a GOING_AWAY refusal leaves a shared token for the next session on moq-transport", async () => { + const protocol = Ietf.ALPN.DRAFT_22; + const handle = new Handle(); + const first = await connect({ publish: new OriginProducer(), protocol, auth: handle }); + await waitFor(handle.grant, (g) => g !== undefined); + const read = spyOn(IetfAuthWire.prototype, "read").mockResolvedValue({ + refused: new StreamError(StreamCode.GoingAway), + }); + const warned = spyOn(console, "warn").mockImplementation(() => {}); + let rejected: unknown; + const adding = handle.add("token"); + adding.catch((err) => { + rejected = err; + }); + try { + await settles(() => warned.mock.calls.some(([line]) => line === "auth token refused")); + for (let i = 0; i < 10; i++) await nextTask(); + expect(rejected).toBeUndefined(); + } finally { + read.mockRestore(); + warned.mockRestore(); + first.client.abort(); + first.server.abort(); + } + + const second = await connect({ publish: new OriginProducer(), protocol, auth: handle }); + answer(second.server.auth.requests(), (token) => (token ? grant(["second"], []) : grant([], []))); + const token = await adding; + await waitFor(token.grant, (g) => g?.publish.equals(patterns("second")) === true); + second.client.abort(); + second.server.abort(); +}); + // lite-07 presents the connection's credential on an Auth Stream right away; lite-06 never // opens one. test.each([ diff --git a/js/net/src/auth.ts b/js/net/src/auth.ts index d226e3ea335..0a4b2685a09 100644 --- a/js/net/src/auth.ts +++ b/js/net/src/auth.ts @@ -8,16 +8,22 @@ * 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. * {@link Handle.grant} is the union of every token this side presented, and {@link Handle.add} - * presents another without reconnecting, returning its {@link Auth}. Mirrors the Rust - * `moq_net::auth`. + * presents another without reconnecting, returning its {@link Auth}. + * + * 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 + * {@link Auth}s keeps it across a reconnect. Only the peer's verdict ends such a token; a + * session ending leaves it for the next. Mirrors the Rust `moq_net::auth`. * * @module */ import type { Getter } from "@moq/signals"; -import { SessionCode } from "./error.ts"; +import type { SessionCode } from "./error.ts"; import type * as Path from "./path.ts"; +export { Duplicate, Handle, Unsupported } from "./auth_session.ts"; + /** * What a peer lets this side do, in this side's own paths (relative to the session). * @@ -32,21 +38,21 @@ export interface Grant { expires?: number; } -/** The session or peer carries no AUTH exchange, or takes no tokens in band. */ -export class Unsupported extends Error { - /** The moq-lite session code for this condition. */ - readonly code = SessionCode.Unsupported; - constructor(message = "auth unsupported") { - super(message); - this.name = "Unsupported"; - } -} - -/** A token this side presented, which a route can name. {@link Auth.close} withdraws it. */ +/** + * A token this side presented, which a route can name, on every session of its handle. + * {@link Auth.close} withdraws it. + */ export interface Auth { - /** This token's own grant: undefined until the peer answers, and again once it ends. */ + /** + * This token's own grant on the live session: undefined until its peer answers, while no + * session is live, and again once it ends. + */ readonly grant: Getter; - /** Settles when the token ends: the peer's revocation, or its withdrawal. */ + /** + * Settles when the token ends for good: the peer's refusal or revocation, `null` when the + * peer closed it without one or it was withdrawn, or the handle's end. A session ending is + * not one: the next session presents it again. + */ readonly closed: PromiseLike; /** Withdraw the token, removing its grant from the union and remaking any request that carries it without it. */ close(): void; @@ -95,36 +101,8 @@ export interface Issued { * @internal See {@link Request}. */ export interface Requests { - /** The next token the peer presents, or undefined once the session ends. */ + /** The next token the peer presents, or undefined once the handle ends. */ next(): Promise; /** Refuse every later token. */ close(): void; } - -/** A session's tokens and grants. See the module docs. */ -export interface Handle { - /** The union of every grant this side holds: undefined until the peer first answers. */ - readonly grant: Getter; - - /** - * Present another token, resolving once the peer answers it. Rejects with - * {@link Unsupported} when the session or the peer takes no tokens in band, or with the - * peer's refusal as an `Error.Session`. On moq-transport without AUTH it resolves at - * once with no grant: only a request whose route names it carries it. - */ - add(token: string | Uint8Array): Promise; - - /** - * Answer every token the peer presents, instead of the default: granting what this side - * publishes and consumes for the connection's own credential and refusing any other. - * - * Call it as soon as connect or accept resolves, in the same task: whoever answers is - * decided once, right after. Throws once that has happened. - * - * The peer's stream credit bounds how many tokens wait unanswered, since each holds its - * stream open until answered or withdrawn. A withdrawn token leaves the queue. - * - * @internal See {@link Request}. - */ - requests(): Requests; -} diff --git a/js/net/src/auth_session.ts b/js/net/src/auth_session.ts index 76532ae9955..8ccc1fae9f7 100644 --- a/js/net/src/auth_session.ts +++ b/js/net/src/auth_session.ts @@ -1,6 +1,6 @@ -import { Instant, Nano, now, wall } from "@moq/clock"; +import { Instant, Milli, Nano, now, sleep, wall } from "@moq/clock"; import { type Dispose, type Getter, Once, race, Signal } from "@moq/signals"; -import { type Auth, type Grant, type Handle, type Issued, type Request, type Requests, Unsupported } from "./auth.ts"; +import type { Auth, Grant, Issued, Request, Requests } from "./auth.ts"; import { closeReason, error, ProtocolViolation, SessionCode, SessionError, StreamCode, StreamError } from "./error.ts"; import * as Path from "./path.ts"; import type { Reader, Stream, Writer } from "./stream.ts"; @@ -33,6 +33,40 @@ function grantsEqual(a: Grant | undefined, b: Grant | undefined): boolean { return a.publish.equals(b.publish) && a.subscribe.equals(b.subscribe) && a.expires === b.expires; } +/** The session or peer carries no AUTH exchange, or takes no tokens in band. */ +export class Unsupported extends Error { + /** The moq-lite session code for this condition. */ + readonly code = SessionCode.Unsupported; + constructor(message = "auth unsupported") { + super(message); + this.name = "Unsupported"; + } +} + +/** A handle already backs a session that is open and not draining, or its requests were already taken. */ +export class Duplicate extends Error { + constructor(message = "auth handle already backs an active session") { + super(message); + this.name = "Duplicate"; + } +} + +// How long a token refused for a reason that may pass waits before its session presents it +// again, doubling on each refusal up to RETRY_MAX. +const RETRY_INITIAL = Milli(1000); +const RETRY_MAX = Milli(30_000); + +// Whether a refusal is the acceptor failing to decide rather than its verdict on the token, +// such as an unreachable auth server or a full verification queue. +function transient(err: Error | null): boolean { + return ( + err instanceof SessionError && + (err.code === SessionCode.Internal || + err.code === SessionCode.TooManyRequests || + err.code === SessionCode.Timeout) + ); +} + /** A grant as the wire carries it: the expiry is milliseconds from now. @internal */ export interface WireGrant { publish: Path.Patterns; @@ -88,27 +122,48 @@ function transportClosed(err: unknown): boolean { function cancel(): StreamError { return new StreamError(StreamCode.Cancel, { message: "cancel" }); } +/** One token as one session presents it. */ +class Presented { + readonly token: Uint8Array; + /** The session's own credential, presented at setup on this session alone. */ + readonly setup: boolean; + /** The handle's token this presents, or undefined for the session's own credential. */ + readonly owner?: Token; + + /** The latest grant: undefined before the first, during a retry, and once it ends here. */ + grant?: Grant; + stream?: Stream; + /** Wakes a retry backoff early once the presentation is no longer wanted. */ + retry?: AbortController; + withdrawn = false; + isAnswered = false; + ended = false; -/** One token this side presented. */ -class Presented implements Auth { + constructor(token: Uint8Array, setup: boolean, owner?: Token) { + this.token = token; + this.setup = setup; + this.owner = owner; + } +} + +/** A token the application added to a {@link Handle}, presented on every session of it. */ +class Token implements Auth { readonly grant = new Signal(undefined); readonly closed: Promise; readonly answered: Promise; - readonly setup: boolean; - readonly token: Uint8Array; + readonly bytes: Uint8Array; - stream?: Stream; - withdrawn = false; isAnswered = false; ended = false; + #withdraw: (token: Token) => void; #close!: (err: Error | null) => void; #answer!: () => void; #refuse!: (err: Error) => void; - constructor(token: Uint8Array, setup: boolean) { - this.token = token; - this.setup = setup; + constructor(bytes: Uint8Array, withdraw: (token: Token) => void) { + this.bytes = bytes; + this.#withdraw = withdraw; this.closed = new Promise((resolve) => { this.#close = resolve; }); @@ -138,13 +193,205 @@ class Presented implements Auth { } close() { - if (this.withdrawn) return; - this.withdrawn = true; - // The stream's loop notices and ends the token; one still opening checks on arrival. - // One without a stream to come (carried only by requests) ends here. - if (this.stream) this.stream.abort(cancel()); - else if (this.isAnswered) this.end(null); + this.#withdraw(this); + } +} + +/** The state behind one {@link Handle}: the tokens it presents, and every session it backs. */ +class HandleState { + /** The live session's union. */ + readonly grant = new Signal(undefined); + /** Created by a session for itself, so it ends with that session. */ + own = false; + /** Set once the handle ends. */ + closed?: Error; + /** Every token the application added that has not ended. */ + readonly tokens = new Set(); + /** Every open session built on this handle, oldest first. */ + readonly sessions: AuthSession[] = []; + acceptor: "undecided" | "default" | RequestQueue = "undecided"; + + /** The session the handle backs now: the newest one still open. */ + get live(): AuthSession | undefined { + return this.sessions.at(-1); + } + + /** Fail unless a new session can be built on the handle. */ + vacant() { + if (this.closed) throw this.closed; + if (this.sessions.some((session) => session.active)) throw new Duplicate(); + } + + attach(session: AuthSession) { + this.vacant(); + this.sessions.push(session); + // The successor is live now, so its predecessor's grants no longer apply. + this.refresh(); + } + + /** A session closed: it presents nothing anymore. A handle of its own ends with it. */ + detach(session: AuthSession, err: Error) { + const index = this.sessions.indexOf(session); + if (index >= 0) this.sessions.splice(index, 1); + if (this.own) this.abort(err); + this.refresh(); } + + /** Point the handle's grant, and each token's, at the live session. */ + refresh() { + const live = this.live; + const union = live?.grant.peek(); + if (!grantsEqual(union, this.grant.peek())) this.grant.set(union); + for (const token of this.tokens) { + const grant = live?.grantOf(token); + if (!grantsEqual(grant, token.grant.peek())) token.grant.set(grant); + } + } + + add(bytes: Uint8Array): Token { + if (this.closed) throw this.closed; + // A draining session takes no new tokens, and a handle of its own has no successor. + if (this.own && !this.sessions.some((session) => session.active)) { + throw new StreamError(StreamCode.GoingAway); + } + const token = new Token(bytes, (token) => this.withdraw(token)); + this.tokens.add(token); + for (const session of [...this.sessions]) session.offer(token); + return token; + } + + /** The application dropped the token: every session stops presenting it. */ + withdraw(token: Token) { + if (!this.tokens.delete(token)) return; + for (const session of this.sessions) session.withdraw(token); + token.end(null); + } + + /** The peer's verdict, or the handle's end: the token ends on every session. */ + verdict(token: Token, err: Error | null) { + if (!this.tokens.delete(token)) return; + token.end(err); + for (const session of this.sessions) session.withdraw(token); + } + + /** Decide who answers the peer's tokens, once: the application if it took them. */ + decide(): RequestQueue | undefined { + if (this.acceptor === "undecided") this.acceptor = "default"; + return this.acceptor instanceof RequestQueue ? this.acceptor : undefined; + } + + abort(err: Error) { + if (this.closed) return; + this.closed = err; + for (const token of [...this.tokens]) this.verdict(token, err); + if (this.acceptor instanceof RequestQueue) this.acceptor.close(); + } +} + +const states = new WeakMap(); + +function stateOf(handle: Handle): HandleState { + const state = states.get(handle); + if (!state) throw new TypeError("not an auth handle"); + return state; +} + +/** + * Fail unless a session can be built on `handle`: it ended, or it backs an active session. + * + * @internal + */ +export function vacant(handle: Handle) { + stateOf(handle).vacant(); +} + +/** + * Why `handle` ended, or undefined while it can still back a session. + * + * @internal + */ +export function ended(handle: Handle): Error | undefined { + return stateOf(handle).closed; +} + +/** + * The tokens this side presents and the grant they earn, across every session built on it. + * + * A session made without one creates its own, returned by `Established.auth`, which ends with + * that session. Pass one to `connect` as `auth` instead and it outlives its sessions: each + * presents every token the handle holds, so an {@link Auth} it returned keeps riding a route's + * announcements across a reconnect. It backs one active session at a time, plus the ones + * draining after a GOAWAY. Mirrors the Rust `moq_net::auth::Handle`. + */ +export class Handle { + /** A handle backing no session yet, for `connect`'s `auth` option. */ + constructor() { + states.set(this, new HandleState()); + } + + /** + * The union of every grant this side holds on the live session: undefined until its peer + * first answers a token, while no session is live, and forever on a version without AUTH. + */ + get grant(): Getter { + return stateOf(this).grant; + } + + /** + * Present another token on every session of this handle, resolving once a peer answers it. + * + * 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. 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. + */ + async add(token: string | Uint8Array): Promise { + // A copy, so the caller reusing its buffer never changes what a request carries. + const bytes = typeof token === "string" ? new TextEncoder().encode(token) : token.slice(); + const added = stateOf(this).add(bytes); + await added.answered; + return added; + } + + /** + * Answer every token the peer presents, instead of the default: granting what this side + * publishes and consumes for the connection's own credential and refusing any other. + * + * Call it as soon as connect or accept resolves, in the same task: whoever answers is + * decided once, right after. Throws {@link Duplicate} once that has happened. + * + * The peer's stream credit bounds how many tokens wait unanswered, since each holds its + * stream open until answered or withdrawn. A withdrawn token leaves the queue. + * + * @internal See {@link Request}. + */ + requests(): Requests { + const state = stateOf(this); + if (state.acceptor !== "undecided") throw new Duplicate("auth requests already taken or answered by default"); + const queue = new RequestQueue(); + // A session of its own that carries no AUTH never receives a token. + if (state.closed || (state.own && !state.live?.negotiated)) queue.close(); + state.acceptor = queue; + return queue; + } + + /** + * End the handle with `err`: every token ends, its sessions stop presenting them, and the + * peer's queued tokens are refused. A handle a session created for itself ends with that + * session. + */ + abort(err: Error = new Error("auth handle closed")): void { + stateOf(this).abort(err); + } +} + +/** A handle of a session's own, which ends with it. */ +function ownHandle(): Handle { + const handle = new Handle(); + stateOf(handle).own = true; + return handle; } /** The peer's token, answered by the application. */ @@ -284,28 +531,35 @@ export interface AuthSessionProps { /** How this session's wire carries the exchange, or undefined when it carries none. */ wire?: AuthWire; /** - * Whether a request can carry a token (moq-transport), so {@link AuthSession.add} keeps one - * the wire cannot present, for the announcements that name it. + * Whether a request can carry a token (moq-transport), so a token the wire cannot present + * is kept for the announcements that name it. */ requests?: boolean; /** What the default acceptor grants the peer's connection credential. */ peerGrant: Grant; + /** The handle the session is built on, or undefined for one of its own that ends with it. */ + handle?: Handle; + /** Whether the session is draining after a GOAWAY, which frees its handle for a successor. */ + draining?: () => boolean; } /** - * A session's tokens and grants: presents ours, one stream each, and answers the peer's. - * The wire decides only the encoding; see {@link AuthWire}. + * One session's side of a {@link Handle}: presents the handle's tokens and its own + * credential, one stream each, and answers the peer's. The wire decides only the encoding; + * see {@link AuthWire}. * * @internal */ -export class AuthSession implements Handle { +export class AuthSession { /** The peer sent a malformed AUTH message; the owning connection closes the session. */ readonly violated = new Once(); + /** The handle this session is built on. */ + readonly handle: Handle; + #state: HandleState; #wire?: AuthWire; #requests: boolean; #peerGrant: Grant; - // Every token this session issued, so a request carries only its own. - #issued = new WeakSet(); + #draining: () => boolean; #ended = new Signal(0); #union = new Signal(undefined); @@ -314,10 +568,11 @@ export class AuthSession implements Handle { #scope = new Signal(undefined); // The peer replied to some token, so the union is known even when empty. #replied = false; - #tokens = new Set(); + #presented = new Set(); + // The handle's tokens as this session presents them, so a request carries only those. + #byToken = new Map(); #setupPending = new Signal(0); #pending = new Signal(0); - #acceptor: "undecided" | "default" | RequestQueue = "undecided"; #closed = false; // Whoever answers the peer's tokens is decided once, after the task that established @@ -325,16 +580,28 @@ export class AuthSession implements Handle { // wins, however quickly the peer's first token arrives. #decided = nextTask(); - constructor({ wire, requests, peerGrant }: AuthSessionProps) { + /** Throws {@link Duplicate} while `handle` backs another active session, or the handle's end. */ + constructor({ wire, requests, peerGrant, handle, draining }: AuthSessionProps) { + this.handle = handle ?? ownHandle(); + this.#state = stateOf(this.handle); this.#wire = wire; this.#requests = requests ?? false; this.#peerGrant = peerGrant; + this.#draining = draining ?? (() => false); + this.#state.attach(this); // Present the connection's own credential right away, so both sides learn their // grant without waiting on the app. - if (wire) this.#present(wire, new Uint8Array(), true); + if (wire) this.#present(wire, new Presented(new Uint8Array(), true)); + for (const token of [...this.#state.tokens]) this.offer(token); + } + + /** Whether the session still takes new work: open, with no GOAWAY received. */ + get active(): boolean { + return !this.#closed && !this.#draining(); } + /** The union of this session's grants, undefined until its peer first answers. */ get grant(): Getter { return this.#union; } @@ -349,30 +616,68 @@ export class AuthSession implements Handle { return this.#wire !== undefined; } - async add(token: string | Uint8Array): Promise { - if (this.#closed || (!this.#wire && !this.#requests)) throw new Unsupported(); - // A copy, so the caller reusing its buffer never changes what a request carries. - const bytes = typeof token === "string" ? new TextEncoder().encode(token) : token.slice(); - if (!this.#wire) { - // Only a request carries it, and the peer judges it there: no grant to wait for. - const presented = new Presented(bytes, false); - presented.answer(); - this.#track(presented); - return presented; + /** The handle's `token` grant on this session. */ + grantOf(token: Token): Grant | undefined { + return this.#byToken.get(token)?.grant; + } + + /** Whether this session still presents the handle's `token`. */ + presents(token: Token): boolean { + const presented = this.#byToken.get(token); + return presented !== undefined && !presented.ended && !presented.withdrawn; + } + + /** + * Present one of the handle's tokens on this session. A draining session takes no new + * tokens, so its verdict never ends one its successor carries. + */ + offer(token: Token) { + if (!this.active || this.#byToken.has(token)) return; + if (!this.#wire && !this.#requests) { + this.#state.verdict(token, new Unsupported()); + return; + } + const presented = new Presented(token.bytes, false, token); + this.#byToken.set(token, presented); + if (this.#wire) { + this.#present(this.#wire, presented); + return; } - const presented = this.#present(this.#wire, bytes, false); - await presented.answered; - return presented; + // Only a request carries it, and the peer judges it there: no grant to wait for. + presented.isAnswered = true; + this.#presented.add(presented); + token.answer(); + } + + /** Stop presenting one of the handle's tokens: withdrawn, or ended for good elsewhere. */ + withdraw(token: Token) { + const presented = this.#byToken.get(token); + if (!presented || presented.withdrawn) return; + presented.withdrawn = true; + presented.grant = undefined; + // The stream's loop notices and ends the presentation; one still opening checks on + // arrival, and one waiting to retry wakes. One without a stream to come (carried only by + // requests) ends here. + if (presented.stream) presented.stream.abort(cancel()); + else if (presented.retry) presented.retry.abort(); + else if (!this.#wire) { + presented.ended = true; + this.#presented.delete(presented); + this.#byToken.delete(token); + this.#ended.update((n) => n + 1); + } + this.#recompute(); } /** - * The bytes a request carries for `token`: undefined for none, for one another session - * issued, or for one that ended. + * The bytes a request carries for `token`: undefined for none, for one another handle + * issued, or for one that ended here. */ carried(token: Auth | undefined): Uint8Array | undefined { - if (!this.#requests || !(token instanceof Presented) || !this.#issued.has(token) || token.ended) - return undefined; - return token.token; + if (!this.#requests || !(token instanceof Token)) return undefined; + const presented = this.#byToken.get(token); + if (!presented || presented.ended || presented.withdrawn) return undefined; + return presented.token; } /** Bumps whenever one of this session's tokens ends, so a request carrying it drops it. */ @@ -380,14 +685,6 @@ export class AuthSession implements Handle { return this.#ended; } - requests(): Requests { - if (this.#acceptor !== "undecided") throw new Error("auth requests already taken or answered by default"); - const queue = new RequestQueue(); - if (!this.#wire) queue.close(); - this.#acceptor = queue; - return queue; - } - /** Resolves once every token the session presented at setup has its first reply. */ async setupAnswered(): Promise { while (this.#setupPending.peek() > 0) await this.#setupPending.changed(); @@ -469,12 +766,12 @@ export class AuthSession implements Handle { throw new ProtocolViolation("malformed AUTH", { cause: err }); } await this.#decided; - if (this.#acceptor === "undecided") this.#acceptor = "default"; + const acceptor = this.#state.decide(); const issued = new IssuedGrant(stream, wire); - if (this.#acceptor instanceof RequestQueue) { + if (acceptor) { const request = new PeerRequest(token, issued); - this.#acceptor.push(request); + acceptor.push(request); } else if (token.byteLength > 0) { // Only the connection's own credential has a default answer; a token needs // someone to verify it. @@ -489,130 +786,207 @@ export class AuthSession implements Handle { if (ended instanceof ProtocolViolation) throw ended; } - /** End the session: fail every pending token and close the requests. */ - close() { + /** + * End the session: fail every pending token here, leaving the handle's tokens for the next + * session. A handle of the session's own ends with it, closing the requests. + */ + close(err: Error = new Error("session closed")) { if (this.#closed) return; this.#closed = true; this.#pending.set(0); - for (const token of this.#tokens) token.end(new Error("session closed")); - this.#tokens.clear(); + this.#setupPending.set(0); + for (const presented of this.#presented) { + presented.ended = true; + presented.isAnswered = true; + presented.grant = undefined; + presented.retry?.abort(); + } + this.#presented.clear(); + this.#byToken.clear(); this.#recompute(); - if (this.#acceptor instanceof RequestQueue) this.#acceptor.close(); + this.#state.detach(this, err); } - #present(wire: AuthWire, token: Uint8Array, setup: boolean): Presented { - const presented = new Presented(token, setup); - this.#track(presented); - if (setup) this.#setupPending.update((n) => n + 1); - if (token.byteLength > 0) this.#pending.update((n) => n + 1); + #present(wire: AuthWire, presented: Presented) { + this.#presented.add(presented); + if (presented.setup) this.#setupPending.update((n) => n + 1); + if (presented.token.byteLength > 0) this.#pending.update((n) => n + 1); void this.#run(wire, presented); - return presented; - } - - /** Hold `token` until it ends, so the session's close ends it too. */ - #track(token: Presented) { - this.#tokens.add(token); - this.#issued.add(token); - void token.closed.then(() => { - this.#tokens.delete(token); - this.#ended.update((n) => n + 1); - }); } - async #run(wire: AuthWire, token: Presented) { + async #run(wire: AuthWire, presented: Presented) { let result: Error | null = null; let closed = false; - try { - const stream = await wire.present(token.token); - token.stream = stream; - if (token.withdrawn) throw cancel(); - - for (;;) { - let reply: WireReply | undefined; - try { - reply = await wire.read(stream); - } catch (err) { - if ( - err instanceof StreamError || - err instanceof SessionError || - err instanceof Unsupported || - transportClosed(err) - ) - throw err; - throw new ProtocolViolation("malformed AUTH reply", { cause: err }); - } - if (!reply) { - // The peer ended the grant without revoking it, or closed without ever - // answering. Close our side too, which is what settles its Issued.closed. - result = token.isAnswered ? null : new Unsupported(); + // The peer refused the last attempt with a reply, rather than losing its stream. + let refused = false; + // The peer granted the token, so a clean close ends the grant. A retry clears it. + let granted = false; + // The peer replied to an earlier attempt, so it knows AUTH. + let replied = false; + let delay: Milli | undefined; + for (;;) { + refused = false; + // Whether the peer answered this token, so a close is not the peer predating AUTH: + // a grant on this stream, or a reply to an earlier one that this retry follows. + let answered = replied; + try { + const stream = await wire.present(presented.token); + presented.stream = stream; + if (presented.withdrawn || presented.ended) throw cancel(); + + for (;;) { + let reply: WireReply | undefined; + try { + reply = await wire.read(stream); + } catch (err) { + if ( + err instanceof StreamError || + err instanceof SessionError || + err instanceof Unsupported || + transportClosed(err) + ) + throw err; + throw new ProtocolViolation("malformed AUTH reply", { cause: err }); + } + if (!reply) { + // The peer ended the grant without revoking it, or closed without ever + // answering. Close our side too, which is what settles its Issued.closed. + result = answered ? null : new Unsupported(); + stream.close(); + break; + } + if ("grant" in reply) { + const expires = reply.grant.expires === undefined ? undefined : wall() + reply.grant.expires; + presented.grant = { publish: reply.grant.publish, subscribe: reply.grant.subscribe, expires }; + answered = granted = true; + delay = undefined; + this.#replied = true; + this.#answered(presented); + this.#recompute(); + continue; + } + console.warn("auth token refused", reply.refused); + // A refused setup token leaves an empty union, not an unknown (unrestricted) + // one. A grant the acceptor could not tell, or never judged, is unknown, not empty. + const goingAway = + reply.refused instanceof StreamError && reply.refused.code === StreamCode.GoingAway; + if (!goingAway && !(reply.refused instanceof Unsupported)) this.#replied = true; + result = reply.refused; + replied = true; + // A peer going away judged nothing, so the successor presents the token instead. + refused = !goingAway; stream.close(); break; } - if ("grant" in reply) { - const expires = reply.grant.expires === undefined ? undefined : wall() + reply.grant.expires; - token.grant.set({ publish: reply.grant.publish, subscribe: reply.grant.subscribe, expires }); - this.#replied = true; - this.#answered(token); - this.#recompute(); - continue; + } catch (err: unknown) { + closed = transportClosed(err); + if (presented.withdrawn || presented.ended) { + result = null; + } else if (!answered && err instanceof StreamError) { + // A peer that predates AUTH resets a stream type it does not know. + result = new Unsupported(); + } else { + result = error(err); + if (result instanceof ProtocolViolation && !this.#closed && this.violated.peek() === undefined) { + this.violated.set(result); + } + if (!this.#closed) console.warn("auth token ended", result); } - console.warn("auth token refused", reply.refused); - // A refused setup token leaves an empty union, not an unknown (unrestricted) - // one. A grant the acceptor could not tell, or never judged, is unknown, not empty. - const unjudged = - reply.refused instanceof Unsupported || - (reply.refused instanceof StreamError && reply.refused.code === StreamCode.GoingAway); - if (!unjudged) this.#replied = true; - result = reply.refused; - stream.close(); - break; } - } catch (err: unknown) { - closed = transportClosed(err); - if (token.withdrawn) { + presented.stream = undefined; + + // A refusal that may pass, such as an unreachable auth server: present the token + // again after a backoff while it is still wanted here. Only moq-lite's AUTH_ERROR + // carries such a code. + if (!refused || !transient(result) || this.#closed || presented.withdrawn) break; + presented.grant = undefined; + granted = false; + // Unanswered again, as on its first presentation: a request or publication the token + // might cover waits for the retry's verdict rather than being judged without it. + this.#unsettle(presented); + this.#recompute(); + delay = delay === undefined ? RETRY_INITIAL : Milli(Math.min(delay * 2, RETRY_MAX)); + presented.retry = new AbortController(); + await sleep(Nano.fromMilli(delay), { signal: presented.retry.signal }).catch(() => void 0); + presented.retry = undefined; + if (this.#closed || presented.withdrawn || presented.ended) { + refused = false; result = null; - } else if (!token.isAnswered && err instanceof StreamError) { - // A peer that predates AUTH resets a stream type it does not know. - result = new Unsupported(); - } else { - result = error(err); - if (result instanceof ProtocolViolation && !this.#closed && this.violated.peek() === undefined) { - this.violated.set(result); - } - if (!this.#closed) console.warn("auth token ended", result); + break; + } + // After a GOAWAY the peer must not see new streams: the successor presents it. + if (this.#draining()) { + refused = false; + result = new StreamError(StreamCode.GoingAway); + break; } } + // Already ended by the session's close. + if (presented.ended) return; + presented.ended = true; + presented.grant = undefined; // A token that ends unanswered counts as answered for enforcement: its refusal is // the reply. - const unanswered = !token.isAnswered; - token.end(result); - if (unanswered && token.setup) this.#setupPending.update((n) => n - 1); - if (unanswered && token.token.byteLength > 0 && !this.#closed) this.#pending.update((n) => n - 1); - this.#tokens.delete(token); + this.#settle(presented); + this.#presented.delete(presented); + const owner = presented.owner; + if (owner && this.#byToken.get(owner) === presented) this.#byToken.delete(owner); + this.#ended.update((n) => n + 1); + // Only the peer's verdict ends one of the handle's tokens on every session: a refusal, + // Unsupported, or a clean close after a grant. Anything else leaves it for the next. + // A draining session's verdict is stale while an active session still presents the + // token: that session's own answer decides, even mid-retry with no grant right now. + const superseded = + !this.active && + owner !== undefined && + this.#state.sessions.some((session) => session !== this && session.active && session.presents(owner)); + // A handle of the session's own has no next session to present it, so any end is final. + const verdict = + !this.#closed && + !presented.withdrawn && + !superseded && + (this.#state.own || refused || result instanceof Unsupported || (result === null && granted)); + if (owner && verdict) this.#state.verdict(owner, result); // Closed before the union shrinks, so the scope never reads it as a revocation. if (closed) this.close(); this.#recompute(); } - #answered(token: Presented) { - if (token.isAnswered) return; - token.answer(); - if (token.setup) this.#setupPending.update((n) => n - 1); - if (token.token.byteLength > 0 && !this.#closed) this.#pending.update((n) => n - 1); + /** The peer granted `presented`: it counts as answered, and so does the handle's token. */ + #answered(presented: Presented) { + this.#settle(presented); + presented.owner?.answer(); + } + + /** Stop counting `presented` as unanswered, for enforcement and the setup wait. */ + #settle(presented: Presented) { + if (presented.isAnswered) return; + presented.isAnswered = true; + if (presented.setup) this.#setupPending.update((n) => n - 1); + if (presented.token.byteLength > 0 && !this.#closed) this.#pending.update((n) => n - 1); + } + + /** Count `presented` as unanswered again, as a retry presents it anew. */ + #unsettle(presented: Presented) { + if (!presented.isAnswered) return; + presented.isAnswered = false; + if (presented.setup) this.#setupPending.update((n) => n + 1); + if (presented.token.byteLength > 0) this.#pending.update((n) => n + 1); } #recompute() { const granted: Grant[] = []; - for (const token of this.#tokens) { - const grant = token.grant.peek(); - if (grant) granted.push(grant); + for (const presented of this.#presented) { + if (presented.grant) granted.push(presented.grant); } // Undefined until the first reply; an empty union afterwards grants nothing. - if (granted.length === 0 && !this.#replied) return; - const next = union(granted); - if (!grantsEqual(next, this.#union.peek())) this.#union.set(next); - if (!this.#closed && !grantsEqual(next, this.#scope.peek())) this.#scope.set(next); + if (granted.length > 0 || this.#replied) { + const next = union(granted); + if (!grantsEqual(next, this.#union.peek())) this.#union.set(next); + if (!this.#closed && !grantsEqual(next, this.#scope.peek())) this.#scope.set(next); + } + this.#state.refresh(); } } diff --git a/js/net/src/connection/connect.ts b/js/net/src/connection/connect.ts index 9c493261bb2..c93137f6d4b 100644 --- a/js/net/src/connection/connect.ts +++ b/js/net/src/connection/connect.ts @@ -1,6 +1,8 @@ import { Elapsed, Milli, Nano, sleep, timeout } from "@moq/clock"; import Session, { type Version as QmuxVersion } from "@moq/qmux"; import { race } from "@moq/signals"; +import type * as Auth from "../auth.ts"; +import { vacant } from "../auth_session.ts"; import { ConnectTimeout, error, fromClose, ProtocolViolation, sessionCause } from "../error.ts"; import * as Ietf from "../ietf/index.ts"; import * as Message from "../ietf/message.ts"; @@ -222,6 +224,16 @@ export interface ConnectProps { /** The moq-transport extensions to offer, each on unless set to `false`. */ extensions?: Extensions.Extensions; + /** + * Build the session on `auth`, so it presents every token the handle holds and a route + * naming one of its `Auth`s keeps it across a reconnect. Omit to give the session a handle + * of its own, which ends with it. + * + * The handle backs one active session at a time, plus the ones draining after a GOAWAY: + * connecting another while one is still active rejects with {@link Auth.Duplicate}. + */ + auth?: Auth.Handle; + /** * The implementation this side advertises in SETUP. Defaults to {@link IMPLEMENTATION}; * a wrapper prepends its own token, like `my-app/1.0 moq-net-js/0.4.2`. Empty sends nothing. @@ -246,6 +258,8 @@ export interface ConnectProps { /** The per-session wiring shared by every negotiated protocol path. */ type SessionProps = { publish?: OriginConsumer; + /** The auth handle the session is built on, or undefined for one of its own. */ + handle?: Auth.Handle; /** Whether this side dialed; only the dialing side aborts on a publication its grant does not cover. */ client: boolean; /** The moq-transport extensions this side offers. */ @@ -273,6 +287,8 @@ export async function connect({ url, ...props }: ConnectProps): Promise, abort: P const url = dialable(raw); const wiring: SessionProps = { publish: props.publish, + handle: props.auth, client: true, extensions: Extensions.offered(props.extensions), implementation: props.implementation ?? IMPLEMENTATION, diff --git a/js/net/src/connection/established.ts b/js/net/src/connection/established.ts index df8ef882ed2..89c09ba01b8 100644 --- a/js/net/src/connection/established.ts +++ b/js/net/src/connection/established.ts @@ -33,6 +33,9 @@ export interface Established { * presents. On moq-lite-07-wip, and on moq-transport draft-17+ when both sides negotiate * MoQ Auth, each side presents its connection's credential right after setup. * Otherwise the grant stays undefined. + * + * The handle passed to `connect` as `auth`, or else one the session created for itself, + * which ends with it. */ readonly auth: Auth.Handle; diff --git a/js/net/src/connection/forward.test.ts b/js/net/src/connection/forward.test.ts index ed3350c345a..9a737265a5e 100644 --- a/js/net/src/connection/forward.test.ts +++ b/js/net/src/connection/forward.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, expect, jest, spyOn, test } from "bun:test"; import { Once } from "@moq/signals"; import * as Announce from "../announced.ts"; +import { AuthSession } from "../auth_session.ts"; import { type Consumer as BroadcastConsumer, Producer as BroadcastProducer } from "../broadcast.ts"; import * as Epoch from "../epoch.ts"; import { Route } from "../hop.ts"; @@ -49,7 +50,11 @@ class FakeSession { #die!: (reason: Error | null) => void; constructor() { - registerWire(this, { consume: (path) => this.consume(path), goaway: new Once() }); + registerWire(this, { + consume: (path) => this.consume(path), + goaway: new Once(), + auth: new AuthSession({ peerGrant: { publish: new Path.Patterns(), subscribe: new Path.Patterns() } }), + }); this.closed = new Promise((resolve) => { this.#die = resolve; }); diff --git a/js/net/src/connection/goaway.ts b/js/net/src/connection/goaway.ts index 75fca7f1d7e..73e0f2b0f9b 100644 --- a/js/net/src/connection/goaway.ts +++ b/js/net/src/connection/goaway.ts @@ -22,8 +22,9 @@ export interface Drain { * * - `same-host` (the default) follows it only onto the host already dialed, so a peer can move * the connection between ports or schemes but not to another host. - * - `follow` also lets the peer name the host. The name is dialed as written, so only use it - * with a relay trusted not to point the connection into the local network. + * - `follow` also lets the peer name the host. The name is dialed as written, and the new host + * is presented every token on the connection's `auth` handle, so only use it with a relay + * trusted not to point the connection into the local network or at an untrusted host. * - `ignore` reconnects to the current URL whatever the peer names. * * A malformed or refused URI ends the connection with {@link RefusedRedirect}. diff --git a/js/net/src/connection/migrate.test.ts b/js/net/src/connection/migrate.test.ts index c65b21b3d12..5c4c1a2485f 100644 --- a/js/net/src/connection/migrate.test.ts +++ b/js/net/src/connection/migrate.test.ts @@ -6,6 +6,7 @@ import { createMockTransportPair } from "../mock.ts"; import { Producer as OriginProducer } from "../origin.ts"; import * as Path from "../path.ts"; import { Stream } from "../stream.ts"; +import { nextTask } from "../util/yield.ts"; import { accept } from "./accept.ts"; import { Connection, resetShared } from "./pool.ts"; import { Redirects, Reload } from "./reload.ts"; @@ -289,6 +290,84 @@ test("a redirect onto an already pooled key leaves both entries to their handles } }); +test("the connection's tokens ride every session across a GOAWAY", async () => { + const clock = new Manual(); + const installed = clock.install(); + // Bounded by task turns rather than wall time, advancing the clock a millisecond per turn + // so the reconnect's delay passes long before the draining session's handover. + const until = async (done: () => boolean) => { + for (let i = 0; i < 100 && !done(); i++) { + await nextTask(); + await clock.advance(Nano.fromMilli(Milli(1))); + } + expect(done()).toBe(true); + }; + + // Every dial gets a lite-07 session that grants each token it is presented. + const relays: { server: WebTransport; tokens: string[]; withdrawn: string[] }[] = []; + const stub = function StubWebTransport(dialed: string | URL) { + const pair = createMockTransportPair(Lite.ALPN_07_WIP); + const relay = { server: pair.server, tokens: [] as string[], withdrawn: [] as string[] }; + relays.push(relay); + void accept({ transport: pair.server, url: new URL(dialed) }).then((session) => { + const requests = session.auth.requests(); + void (async () => { + for (;;) { + const request = await requests.next(); + if (!request) break; + const token = new TextDecoder().decode(request.token); + relay.tokens.push(token); + const issued = request.accept({ + publish: new Path.Patterns([Path.Pattern.all()]), + subscribe: new Path.Patterns(), + }); + void issued.closed.then(() => relay.withdrawn.push(token)); + } + })(); + }); + return pair.client; + }; + globalThis.WebTransport = stub as unknown as typeof WebTransport; + + const reload = new Reload({ + url, + websocket: { enabled: false }, + versions: [Lite.ALPN_07_WIP], + goaway: { handover }, + delay: { initial: Milli(1) }, + }); + try { + const token = await reload.auth.add("token"); + const first = relays[0]; + if (!first) throw new Error("no first dial"); + expect(first.tokens).toContain("token"); + + const stream = await Stream.open(first.server, { version: Lite.Version.DRAFT_07 }); + await stream.writer.u53(Lite.StreamId.Goaway); + await new Lite.Goaway("").encode(stream.writer, Lite.Version.DRAFT_07); + + // The replacement presents it with no help from the application, while the draining + // session keeps presenting it beside its successor. + await until(() => relays[1]?.tokens.includes("token") === true); + await until(() => token.grant.peek() !== undefined && reload.established.peek() !== undefined); + await nextTask(); + expect(first.withdrawn).not.toContain("token"); + let ended = false; + void token.closed.then(() => { + ended = true; + }); + await nextTask(); + expect(ended).toBe(false); + + // The tokens end with the loop. + reload.close(); + expect(await token.closed).toBeInstanceOf(Error); + } finally { + reload.close(); + installed.close(); + } +}); + // How long each session on the bouncing fleet lives before its peer redirects it away. const life = Milli(40); diff --git a/js/net/src/connection/pool.ts b/js/net/src/connection/pool.ts index cf7e8feba36..590019567d1 100644 --- a/js/net/src/connection/pool.ts +++ b/js/net/src/connection/pool.ts @@ -7,6 +7,7 @@ import { Milli, Nano } from "@moq/clock"; import { Effect, type GetPromise, type Getter, Once, Signal } from "@moq/signals"; import * as Announce from "../announced.ts"; +import type * as Auth from "../auth.ts"; import type { Handle } from "../bandwidth.ts"; import * as Origin from "../origin.ts"; import * as Path from "../path.ts"; @@ -193,6 +194,17 @@ export class Connection { */ readonly origin: Getter; + /** + * The auth handle every session presents, or undefined while disabled or URL-less. + * + * Every session the connection makes presents the tokens it holds, so a route naming one + * keeps it across a reconnect, and its grant follows the live session. A private + * connection keeps one handle across URL changes, so its tokens go to the new URL, and + * starts another when `versions` changes. A shared connection's is the one every other + * handle on its URL uses, since they share the session. + */ + readonly auth: Getter; + /** * Settles once this handle is released via {@link close}: `null` on a clean close, or * the abort {@link Error}. Peek it synchronously (`undefined` while open), observe it @@ -208,6 +220,7 @@ export class Connection { readonly #probe = new Signal | undefined>(undefined); readonly #origin = new Signal(undefined); readonly #bandwidth = new Signal(undefined); + readonly #auth = new Signal(undefined); readonly #error = new Signal(undefined); #signals: Effect; @@ -237,6 +250,7 @@ export class Connection { this.probe = new Follow(this.#probe); this.origin = this.#origin; this.bandwidth = this.#bandwidth; + this.auth = this.#auth; this.transport = this.#signals.computed((effect) => effect.get(this.#established)?.transport); const linger = props?.linger; @@ -265,6 +279,7 @@ export class Connection { effect.set(this.#origin, lease.origin, undefined); effect.set(this.#bandwidth, lease.connection.bandwidth, undefined); + effect.set(this.#auth, lease.connection.auth, undefined); // Proxy the shared loop's outputs, so this handle reads like its own connection. effect.run((nested) => nested.set(this.#status, nested.get(lease.connection.status), "disconnected")); @@ -314,6 +329,7 @@ export class Connection { if (!effect.get(this.enabled) || !effect.get(href)) return; effect.set(this.#origin, origin, undefined); effect.set(this.#bandwidth, loop.bandwidth, undefined); + effect.set(this.#auth, loop.auth, undefined); }); signals.run((effect) => effect.set(this.#status, effect.get(loop.status), "disconnected")); signals.run((effect) => effect.set(this.#established, effect.get(loop.established), undefined)); diff --git a/js/net/src/connection/reload.test.ts b/js/net/src/connection/reload.test.ts index 1fb9d22cbeb..b4055a8e007 100644 --- a/js/net/src/connection/reload.test.ts +++ b/js/net/src/connection/reload.test.ts @@ -10,6 +10,7 @@ import * as Lite from "../lite/index.ts"; import { createMockTransportPair } from "../mock.ts"; import { Producer as OriginProducer } from "../origin.ts"; import * as Path from "../path.ts"; +import { nextTask } from "../util/yield.ts"; import { wireOf } from "../wire.ts"; import { type AcceptProps, accept as acceptSession } from "./index.ts"; import { pumpAnnounced, Redirects, Reload, type ReloadProps } from "./reload.ts"; @@ -246,6 +247,53 @@ test("an announced request follows the reconnect on the same handle", async () = } }); +test("an ended auth handle gives up instead of retrying", async () => { + const original = globalThis.WebTransport; + const url = new URL("https://example.com/"); + const servers: Promise<{ abort(): void }>[] = []; + const stub = function StubWebTransport() { + const pair = createMockTransportPair(Lite.ALPN_06); + servers.push(accept(pair.server, url)); + return pair.client; + }; + globalThis.WebTransport = stub as unknown as typeof WebTransport; + const clock = new Manual(); + const installed = clock.install(); + // Bounded by task turns rather than wall time, advancing the clock a millisecond per turn + // so a reconnect, if the loop tried one, would be dialed well within the bound. + const until = async (done: () => boolean) => { + for (let i = 0; i < 100 && !done(); i++) { + await nextTask(); + await clock.advance(Nano.fromMilli(Milli(1))); + } + expect(done()).toBe(true); + }; + + // An unbounded retry window, so only the ended handle can stop the loop. + const reload = new Reload({ + enabled: true, + url, + websocket: { enabled: false }, + delay: { initial: Milli(1), multiplier: 2, max: Milli(1), timeout: Milli(0) }, + }); + const warned = spyOn(console, "warn").mockImplementation(() => {}); + try { + await until(() => reload.established.peek() !== undefined); + const aborted = new Error("auth handle aborted"); + reload.auth.abort(aborted); + (await servers[0])?.abort(); + + await until(() => reload.error.peek() !== undefined); + expect(reload.error.peek()).toBe(aborted); + expect(servers.length).toBe(1); + } finally { + warned.mockRestore(); + reload.close(); + installed.close(); + globalThis.WebTransport = original; + } +}); + test("a page hide after give-up does not retry the refused URL", async () => { const original = globalThis.WebTransport; const previousWindow = globalThis.window; diff --git a/js/net/src/connection/reload.ts b/js/net/src/connection/reload.ts index 5e69dfc5d7b..70111ee06dc 100644 --- a/js/net/src/connection/reload.ts +++ b/js/net/src/connection/reload.ts @@ -1,6 +1,8 @@ import { Instant, Milli, Nano, now, sleep } from "@moq/clock"; import { Effect, type GetPromise, type Getter, Once, Signal } from "@moq/signals"; import * as Announce from "../announced.ts"; +import { Handle as AuthHandle } from "../auth.ts"; +import { ended } from "../auth_session.ts"; import { Allocator } from "../bandwidth.ts"; import { error, reason, SessionCode, SessionError } from "../error.ts"; import type { Consumer as OriginConsumer, Producer as OriginProducer } from "../origin.ts"; @@ -152,6 +154,16 @@ export class Reload { */ readonly bandwidth: Allocator; + /** + * The auth handle every session of the loop is built on, spanning reconnects. + * + * Each session presents every token it holds, so a route naming one keeps it across a + * reconnect, and its grant follows the live session. It spans URL changes too, so its + * tokens go to whatever URL the loop dials next. It ends when the loop closes, and + * aborting it stops the loop at its next reconnect, with the abort's error. + */ + readonly auth = new AuthHandle(); + /** Protocol versions to offer, most preferred first, kept across retries and redirects. */ readonly versions?: readonly string[]; @@ -418,6 +430,7 @@ export class Reload { timeout: this.timeout, publish: this.publish, consume: this.consume, + auth: this.auth, signal, }); } finally { @@ -491,6 +504,13 @@ export class Reload { // Treat teardown as cancellation, not a connection failure. if (signal.aborted) return; + // An ended auth handle refuses every later session, so no retry can recover. + const closed = ended(this.auth); + if (closed) { + this.#giveUp(closed); + return; + } + console.warn("connection error:", err); this.#retry(effect, connected, err); } @@ -678,6 +698,8 @@ export class Reload { close(abort?: Error) { this.#signals.close(); this.#draining?.retire(); + // No session will present the tokens again, so they end with the loop. + this.auth.abort(abort ?? new Error("connection closed")); if (this.#closed.peek() === undefined) this.#closed.set(abort ?? null); } } diff --git a/js/net/src/ietf/connection.ts b/js/net/src/ietf/connection.ts index 5e245a0df71..ca6a5b93457 100644 --- a/js/net/src/ietf/connection.ts +++ b/js/net/src/ietf/connection.ts @@ -68,7 +68,7 @@ export class Connection implements Established { /** Our tokens and grants, when the peer negotiated MoQ Auth; see {@link Established.auth}. */ get auth(): Auth.Handle { - return this.#auth; + return this.#auth.handle; } // Our tokens and grants, and the answers to the peer's (MoQ Auth). @@ -137,6 +137,7 @@ export class Connection implements Established { activeCount = false, cluster, auth = false, + handle, epoch = false, early = [], requestWindow, @@ -170,6 +171,8 @@ export class Connection implements Established { cluster?: Cluster.Hops; /** Whether MoQ Auth is negotiated (draft-17+): offered by both SETUPs. */ auth?: boolean; + /** The auth handle to build the session on, or undefined for one of its own. */ + handle?: Auth.Handle; /** Whether MoQ Epoch is negotiated (draft-17+): offered by both SETUPs. */ epoch?: boolean; /** Uni streams that arrived before the peer's SETUP, type unread (v17+). */ @@ -227,7 +230,13 @@ export class Connection implements Established { publish: new Path.Patterns([Path.Pattern.all()]), subscribe: new Path.Patterns(publish ? [Path.Pattern.all()] : []), }, + handle, + draining: () => this.#goaway.peek() !== undefined, }); + // Left before anything observes the close, so a successor on the same handle never + // finds this session still active. + const leave = () => this.#auth.close(); + void quic.closed.then(leave, leave); this.#client = client; this.#auth.violated.subscribe((err) => { @@ -264,7 +273,11 @@ export class Connection implements Established { grant: this.#auth.scope, auth: this.#auth, }); - registerWire(this, { consume: (path) => this.#subscriber.consume(path), goaway: this.#goaway }); + registerWire(this, { + consume: (path) => this.#subscriber.consume(path), + goaway: this.#goaway, + auth: this.#auth, + }); void this.#run(early); } diff --git a/js/net/src/lite/connection.ts b/js/net/src/lite/connection.ts index a8bb3f25099..adc771443e6 100644 --- a/js/net/src/lite/connection.ts +++ b/js/net/src/lite/connection.ts @@ -59,6 +59,8 @@ export interface ConnectionProps { session?: Stream; /** The origin whose broadcasts are served to the peer. Omit to publish nothing. */ publish?: OriginConsumer; + /** The auth handle to build the session on, or undefined for one of its own. */ + handle?: Auth.Handle; /** * Whether this side dialed. Only the dialing side aborts on a publication its grant does * not cover: a server publishes whatever the peer may read, not what it intends to push. @@ -159,6 +161,7 @@ export class Connection implements Established { version, session, publish, + handle, client = true, implementation = IMPLEMENTATION, }: ConnectionProps) { @@ -185,7 +188,13 @@ export class Connection implements Established { publish: new Path.Patterns([Path.Pattern.all()]), subscribe: new Path.Patterns(publish ? [Path.Pattern.all()] : []), }, + handle, + draining: () => this.#goaway.peek() !== undefined, }); + // Left before anything observes the close, so a successor on the same handle never + // finds this session still active. + const leave = () => this.#auth.close(); + void quic.closed.then(leave, leave); this.#auth.violated.subscribe((err) => { if (!err) return; closeViolation(this.#quic, err); @@ -211,14 +220,18 @@ export class Connection implements Established { this.#implementation = implementation; // Only the lite-05+ Setup Stream carries it. if (!hasSetupStream(version)) this.#peerImplementation.resolve(undefined); - registerWire(this, { consume: (path) => this.#subscriber.consume(path), goaway: this.#goaway }); + registerWire(this, { + consume: (path) => this.#subscriber.consume(path), + goaway: this.#goaway, + auth: this.#auth, + }); void this.#run(); } /** Our tokens and grants; see {@link Established.auth}. */ get auth(): Auth.Handle { - return this.#auth; + return this.#auth.handle; } /** diff --git a/js/net/src/wire.ts b/js/net/src/wire.ts index f9ce377f36c..4e668fe37e8 100644 --- a/js/net/src/wire.ts +++ b/js/net/src/wire.ts @@ -118,6 +118,13 @@ export interface Established { consume(path: Path.Valid): broadcast.Consumer; /** Settles with the peer's GOAWAY; the session keeps serving until it closes. */ readonly goaway: GetPromise; + // private: plumbing + /** + * This session's side of its auth handle. + * + * @internal + */ + readonly auth: import("./auth_session.ts").AuthSession; } /** A produced broadcast's view, which always fetches from its own cache. */ diff --git a/py/moq-rs/moq/session.py b/py/moq-rs/moq/session.py index acf7fce30cb..a0fe61bfd61 100644 --- a/py/moq-rs/moq/session.py +++ b/py/moq-rs/moq/session.py @@ -139,7 +139,8 @@ async def grant_changed(self) -> Grant | None: """Wait for the grant to differ from the one last reported, or held when this handle was made, returning it. - Raises once nothing will change it again, because the session ended.""" + Raises once nothing will change it again: a client's connection or a server's + session ended. A reconnect is not an end.""" return Grant._from_ffi(await self._inner.grant_changed()) async def add(self, token: str) -> AuthToken: @@ -162,7 +163,8 @@ def grant(self) -> Grant | None: return Grant._from_ffi(self._inner.grant()) async def closed(self) -> None: - """Wait for the token to end, raising the peer's revocation or the session's close.""" + """Wait for the token to end, raising the peer's revocation or the end of a client's + connection or a server's session. A reconnect does not end it.""" await self._inner.closed() def cancel(self) -> None: diff --git a/quest/m1/api-audit/net-exports.md b/quest/m1/api-audit/net-exports.md index 02ca6aca806..695c612833b 100644 --- a/quest/m1/api-audit/net-exports.md +++ b/quest/m1/api-audit/net-exports.md @@ -42,7 +42,7 @@ Decided 2026-10-23 (API audit follow-up, maintainer): Kept, so the next audit skips them: `transport::memory` stays public and ungated, since an in-process transport is real API that other crates' tests use. `Route::{auth, with_auth}` stay public, since `doc/concept/standard.md` -documents them and [auth issuer](/quest/m1/auth/issuer.md) extends them +documents them and a shared `auth::Handle` keeps them across a reconnect (decided 2026-10-22, maintainer). Public API: removals and renames in moq-net and `@moq/net`. Wire: none. diff --git a/quest/m1/api-audit/net-names.md b/quest/m1/api-audit/net-names.md index 19a33c0c526..9a8c7d58826 100644 --- a/quest/m1/api-audit/net-names.md +++ b/quest/m1/api-audit/net-names.md @@ -27,9 +27,8 @@ Decided 2026-10-23 (API audit follow-up, maintainer): `session::Stats` reads them from it. `StatsUnavailable` goes; the trait method gets a default. - **Credential.** `auth::Auth` (JS `Auth.Auth`) is one presented credential: - `auth::Credential` in Rust, JS, and the FFI, including moq-tokio's - `connection::auth::Auth`. Update [issuer](/quest/m1/auth/issuer.md) and - the [auth epic](/quest/m1/auth/README.md), which use the old name. + `auth::Credential` in Rust, JS, and the FFI. Update the + [auth epic](/quest/m1/auth/README.md), which uses the old name. - **Info.** `track::Consumer::query() -> Pending` becomes `info() -> Pending`, mirroring JS `Track.Consumer.info()`. `Pending` stays generic over the task so poll callers and `track()` keep diff --git a/quest/m1/auth/README.md b/quest/m1/auth/README.md index 3086a240220..d5f3f2864fb 100644 --- a/quest/m1/auth/README.md +++ b/quest/m1/auth/README.md @@ -144,11 +144,10 @@ because `main` assigned 0x3A to NOT_FETCHABLE first. ## Required -- [Auth outlives its session](/quest/m1/auth/issuer.md) - one `auth::Handle` spans a connection's sessions, so a route's auth survives a reconnect - [Token in band](/quest/m1/auth/token-in-band.md) - extra credentials ride AUTH streams: a session starts on the token its SETUP or URL carried and its AUTH streams add the rest -- [Binding auth polish](/quest/m1/auth/bindings-polish.md) - OBS reconnects with the presented token, one FFI auth handle, Swift grant sequences +- [Binding auth polish](/quest/m1/auth/bindings-polish.md) - OBS reconnects with the presented token, Swift grant sequences - [Re-present a lost token](/quest/m1/auth/token-represent.md) - a token whose AUTH stream ended without a verdict is presented again on the live session ## Related diff --git a/quest/m1/auth/bindings-polish.md b/quest/m1/auth/bindings-polish.md index e6b4850df42..15f1aff2c81 100644 --- a/quest/m1/auth/bindings-polish.md +++ b/quest/m1/auth/bindings-polish.md @@ -2,10 +2,9 @@ ## Goal -Three gaps left by [moq-dev/moq#6137](https://github.com/moq-dev/moq/pull/6137) -close: OBS reconnects with the token the user last presented, moq-ffi wraps -one auth handle type instead of two, and Swift exposes grants as an -`AsyncSequence`. +Two gaps left by [moq-dev/moq#6137](https://github.com/moq-dev/moq/pull/6137) +close: OBS reconnects with the token the user last presented, and Swift +exposes grants as an `AsyncSequence`. ## Plan @@ -14,12 +13,6 @@ Decided 2026-10-21 with the maintainer: one m1 quest. - **OBS reconnect.** A reconnect dials the URL's original token, not the one presented mid-stream through `MoQOutput::PresentToken`. Once tokens can ride SETUP, the presented token replaces it on reconnect. -- **FFI dispatch.** `rs/moq-ffi/src/auth.rs` wraps both - `moq_net::auth::Handle` (server sessions) and moq-tokio's - `connection::auth::Handle` (client sessions). With one handle spanning a - connection, collapse the enum. #6137 decided whichever of it and - [moq-dev/moq#6109](https://github.com/moq-dev/moq/pull/6109) lands second - does this; skip it if that already happened. - **Swift.** Grants arrive as an `AsyncSequence`, once the generated types are `Sendable`. If they still are not, say why in the PR rather than forcing it. @@ -28,5 +21,4 @@ Public API: Swift additive. Wire: none. ## Required -- [Auth outlives its session](/quest/m1/auth/issuer.md) - one handle per connection, which the enum collapse needs - [Token in band](/quest/m1/auth/token-in-band.md) - tokens that ride SETUP, which the OBS reconnect needs diff --git a/quest/m1/auth/issuer.md b/quest/m1/auth/issuer.md deleted file mode 100644 index b7f2b50591c..00000000000 --- a/quest/m1/auth/issuer.md +++ /dev/null @@ -1,55 +0,0 @@ -# [L] Auth outlives its session - -## Goal - -A route on a reconnecting connection names its token once: the same -`moq_net::auth::Auth` keeps riding its announcements on every session the -connection makes, with no re-add or re-announce from the application. - -## Plan - -- Today a `moq_net::auth::Auth` belongs to the session whose `auth::Handle` - added it, and `Route::auth` carries it only on that session, so after a - reconnect the application adds the token again on the new session and - re-announces. `moq_tokio::Connection::auth().add()` keeps a token across - sessions, but it returns moq-tokio's own `connection::auth::Auth`, which a - `Route` cannot name. -- ✅ Decided 2026-10-16 (maintainer, #5695): the long-term shape is a - session-independent issuer in moq-net. An `auth::Handle` outlives sessions - and is passed in with something like `Client::with_auth(handle)` (and the - `Server` equivalent). Each session built on it presents every token the - handle holds, and a `moq_net::auth::Auth` it issued is carried by every - session of that handle. This revisits the "bound to the handle that issued - it" decision of [#5695](https://github.com/moq-dev/moq/pull/5695): the - binding stays, but the handle spans sessions. -- moq-tokio's replay loop moves into moq-net. Today - `rs/moq-tokio/src/connection/auth.rs` spawns a task per added token that - presents it on each new session (`present`, with backoff on a transient - refusal and a final stop on `Unauthorized`). With the handle in moq-net, - `Connection::auth()` returns that `moq_net::auth::Handle`, and - `connection::auth::{Handle, Auth}` go away. -- Decided 2026-10-20 (maintainer): a handle backs one active session at a - time, plus the predecessors it is draining, matching the reconnect code's - single active generation. Attaching a handle to a second concurrent session - fails loudly; independent concurrent sessions use separate handles, so an - `Auth` never has to pick between two live grants. -- An `Auth`'s `grant()` and `closed()` follow the session that is live now: the - grant clears while disconnected and returns once the new session's peer - answers, and `closed()` settles only on a refusal or when the handle ends. -- The ietf publisher's check "presented on this session" (the route's `Auth` - was issued by this session's handle) keeps working unchanged once the handle - is shared, so a route survives a reconnect with no re-announce from the app. -- Mirror in `js/net`: the JS `Handle` lives on the reconnecting connection - rather than one session, and `Route.auth` follows it. -- Update `doc/concept/moq-lite.md`, `doc/concept/standard.md` and - `doc/lib/js/net.md`, which today say a reconnect means adding the token - again and re-announcing. - -Public API: breaking in moq-net (`Handle` construction and the `Client`/`Server` -builders), moq-tokio (the `connection::auth` types go away), and js/net. Wire: -none. - -## Related - -- [#5695](https://github.com/moq-dev/moq/pull/5695) - adds `Route::auth`, bound to one session's handle -- `rs/moq-ffi/src/auth.rs` - wraps both the session and the connection handle behind one `MoqAuth`; collapse it onto this shape diff --git a/quest/m1/auth/token-represent.md b/quest/m1/auth/token-represent.md index 991c8be1f4d..59a465634aa 100644 --- a/quest/m1/auth/token-represent.md +++ b/quest/m1/auth/token-represent.md @@ -8,14 +8,10 @@ moq-transport. The session never runs on without a token it was given. ## Plan -After [Auth outlives its session](/quest/m1/auth/issuer.md), a token whose -AUTH stream is lost waits for the next session. The moq-transport presenter -has no retry loop at all. Retry on the live session with the connection's -backoff, stop on a verdict or session end, and test both wires with mocked -time. +Since [moq-dev/moq#6109](https://github.com/moq-dev/moq/pull/6109), a token +whose AUTH stream is lost waits for the next session. The moq-transport +presenter has no retry loop at all. Retry on the live session with the +transient-refusal backoff (1s doubling to 30s), stop on a verdict or session +end, and test both wires with mocked time. Public API: none expected. Wire: none. - -## Required - -- [Auth outlives its session](/quest/m1/auth/issuer.md) - the shared handle that presents the token diff --git a/rs/moq-ffi/src/auth.rs b/rs/moq-ffi/src/auth.rs index 731143aeff9..3120239b778 100644 --- a/rs/moq-ffi/src/auth.rs +++ b/rs/moq-ffi/src/auth.rs @@ -32,150 +32,23 @@ impl From for MoqGrant { } } -/// Where a session's tokens live: on the session itself, on a connection that presents -/// them again on every reconnect, or on a browser session once its dial completes. -#[derive(Clone)] -pub(crate) enum Handle { - Session(moq_net::auth::Handle), - #[cfg(not(target_arch = "wasm32"))] - Connection(moq_tokio::connection::auth::Handle), - /// A browser session still dialing when the handle was taken. - #[cfg(target_arch = "wasm32")] - Dial(crate::session::Dial), -} - -impl Handle { - fn grant(&self) -> Watch { - match self { - Self::Session(handle) => Watch::Session(handle.grant()), - #[cfg(not(target_arch = "wasm32"))] - Self::Connection(handle) => Watch::Connection(handle.grant()), - #[cfg(target_arch = "wasm32")] - Self::Dial(dial) => match dial.session() { - Some(session) => Watch::Session(session.auth().grant()), - None => Watch::Dial { - dial: dial.clone(), - watch: None, - }, - }, - } - } - - async fn add(self, token: String) -> Result { - match self { - Self::Session(handle) => add(handle, token).await, - #[cfg(not(target_arch = "wasm32"))] - Self::Connection(handle) => Ok(Token::Connection( - handle.add(token).await.map_err(crate::session::map_closed_error)?, - )), - // The token waits for the handshake; a dial that ends first refuses it. - #[cfg(target_arch = "wasm32")] - Self::Dial(dial) => match dial.established().await? { - Some(session) => add(session.auth(), token).await, - None => Err(MoqError::Closed), - }, - } +/// Present `token` on every session of `handle`, resolving once a peer accepts it. +async fn add(handle: moq_net::auth::Handle, token: String) -> Result { + // Without AUTH, moq-transport accepts a token only for the requests that name it, but + // nothing here can name one, so it would never be sent. The answer says how the peer + // accepted it, which a grant missing after its session drops cannot. + match handle.add(token).await? { + (auth, moq_net::auth::Answer::Granted) => Ok(auth), + (_, moq_net::auth::Answer::Requests) => Err(MoqError::Unsupported), } } -async fn add(handle: moq_net::auth::Handle, token: String) -> Result { - let auth = handle.add(token).await?; - // A token the peer accepted carries a grant until it ends. Without AUTH, - // moq-transport accepts one at once for the requests that name it, but nothing - // here can name one, so it would never be sent. One already revoked is left for - // `closed` to report. moq-tokio refuses the same for a client's connection. - if auth.grant().peek().is_none() && poll_now(auth.closed()).is_pending() { - return Err(MoqError::Unsupported); - } - Ok(Token::Session(auth)) -} - /// Poll `future` once without waiting. fn poll_now(future: F) -> std::task::Poll { let future = std::pin::pin!(future); std::future::Future::poll(future, &mut std::task::Context::from_waker(std::task::Waker::noop())) } -enum Watch { - Session(moq_net::auth::Watch), - #[cfg(not(target_arch = "wasm32"))] - Connection(moq_tokio::connection::auth::Watch), - /// A browser session still dialing, which has no grant until its session's watch exists. - #[cfg(target_arch = "wasm32")] - Dial { - dial: crate::session::Dial, - watch: Option, - }, -} - -impl Watch { - fn peek(&self) -> Option { - match self { - Self::Session(watch) => watch.peek(), - #[cfg(not(target_arch = "wasm32"))] - Self::Connection(watch) => watch.peek(), - #[cfg(target_arch = "wasm32")] - Self::Dial { dial, watch } => match watch { - Some(watch) => watch.peek(), - None => dial.session().and_then(|session| session.auth().grant().peek()), - }, - } - .map(Into::into) - } - - async fn changed(&mut self) -> Result, MoqError> { - let grant = match self { - Self::Session(watch) => watch.changed().await?, - #[cfg(not(target_arch = "wasm32"))] - Self::Connection(watch) => watch.changed().await.map_err(crate::session::map_closed_error)?, - #[cfg(target_arch = "wasm32")] - Self::Dial { dial, watch } => { - let watch = match watch { - Some(watch) => watch, - None => { - let session = dial.established().await?.ok_or(MoqError::Closed)?; - let fresh = watch.insert(session.auth().grant()); - // Dialing reported no grant, so one the session already holds is the change. - if let Some(grant) = fresh.peek() { - return Ok(Some(grant.into())); - } - fresh - } - }; - watch.changed().await? - } - }; - Ok(grant.map(Into::into)) - } -} - -enum Token { - Session(moq_net::auth::Auth), - #[cfg(not(target_arch = "wasm32"))] - Connection(moq_tokio::connection::auth::Auth), -} - -impl Token { - fn grant(&self) -> Watch { - match self { - Self::Session(auth) => Watch::Session(auth.grant()), - #[cfg(not(target_arch = "wasm32"))] - Self::Connection(auth) => Watch::Connection(auth.grant()), - } - } - - async fn closed(&self) -> Result<(), MoqError> { - match self { - Self::Session(auth) => match auth.closed().await { - moq_net::Error::Cancel => Ok(()), - err => Err(err.into()), - }, - #[cfg(not(target_arch = "wasm32"))] - Self::Connection(auth) => auth.closed().await.map_err(crate::session::map_closed_error), - } - } -} - /// A session's authorization: the grant its peer gave it, and more tokens to present. /// /// Returned by [`MoqSession::auth`](crate::session::MoqSession::auth). AUTH is only on @@ -183,16 +56,16 @@ impl Token { /// elsewhere the grant stays `None`. #[derive(uniffi::Object)] pub struct MoqAuth { - handle: Handle, + handle: moq_net::auth::Handle, /// The watch behind [`grant`](Self::grant), which reads without the lock. - peek: Watch, + peek: moq_net::auth::Watch, /// The watch behind [`grant_changed`](Self::grant_changed), which tracks what this /// handle last reported. Concurrent callers share it, so each watcher takes its own handle. - changed: Task, + changed: Task, } impl MoqAuth { - pub(crate) fn new(handle: Handle) -> Self { + pub(crate) fn new(handle: moq_net::auth::Handle) -> Self { // Start from the grant as it is now, so a watcher reads that with `grant` and waits // in `grant_changed` only for what follows, never for the same grant twice. let mut changed = handle.grant(); @@ -212,7 +85,7 @@ impl MoqAuth { /// A client session reads `None` while it reconnects. pub fn grant(&self) -> Option { let _guard = crate::ffi::enter(); - self.peek.peek() + self.peek.peek().map(Into::into) } /// Wait for the grant to differ from the one this handle last reported, or held when it was @@ -221,9 +94,12 @@ impl MoqAuth { /// Calls on one handle share what it last reported; an independent watcher takes its own /// handle from [`MoqSession::auth`](crate::session::MoqSession::auth). /// - /// Errors once nothing will change it again, because the session ended. + /// Errors once nothing will change it again: a client's connection or a server's session + /// ended. A reconnect is not an end. pub async fn grant_changed(&self) -> Result, MoqError> { - self.changed.run(|mut watch| async move { watch.changed().await }).await + self.changed + .run(|mut watch| async move { Ok(watch.changed().await?.map(Into::into)) }) + .await } /// Present another token, resolving once the peer accepts it. @@ -237,7 +113,7 @@ impl MoqAuth { /// without AUTH, or moq-transport without the MoQ Auth extension. pub async fn add(&self, token: String) -> Result, MoqError> { let handle = self.handle.clone(); - let token = crate::ffi::detached(handle.add(token)).await?; + let token = crate::ffi::detached(add(handle, token)).await?; Ok(Arc::new(MoqAuthToken::new(token))) } } @@ -248,12 +124,12 @@ impl MoqAuth { #[derive(uniffi::Object)] pub struct MoqAuthToken { /// The watch behind [`grant`](Self::grant), which does not keep the token presented. - peek: Watch, - token: Task, + peek: moq_net::auth::Watch, + token: Task, } impl MoqAuthToken { - fn new(token: Token) -> Self { + fn new(token: moq_net::auth::Auth) -> Self { Self { peek: token.grant(), token: Task::new(token), @@ -266,13 +142,21 @@ impl MoqAuthToken { /// This token's own grant, or `None` while the peer has not answered it or after it ended. pub fn grant(&self) -> Option { let _guard = crate::ffi::enter(); - self.peek.peek() + self.peek.peek().map(Into::into) } /// Wait for the token to end: `Ok` when the peer closed it without an error, the peer's - /// revocation as [`MoqError::Protocol`], or the session's close. + /// revocation as [`MoqError::Protocol`], or the end of a client's connection or a server's + /// session as an error. A reconnect does not end it. pub async fn closed(&self) -> Result<(), MoqError> { - self.token.run(|token| async move { token.closed().await }).await + self.token + .run(|token| async move { + match token.closed().await { + moq_net::Error::Cancel => Ok(()), + err => Err(err.into()), + } + }) + .await } /// Withdraw the token, removing its grant from the union. @@ -282,3 +166,91 @@ impl MoqAuthToken { self.token.cancel(); } } + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use crate::session::{MoqClient, MoqClientConfig}; + + const TIMEOUT: Duration = Duration::from_secs(10); + + /// Wait until `auth` reports `want`, following its changes. + async fn await_grant(auth: &super::MoqAuth, want: Option>) { + let publish = || auth.grant().map(|grant| grant.publish); + tokio::time::timeout(TIMEOUT, async { + while publish() != want { + auth.grant_changed().await.expect("grant watch ended"); + } + }) + .await + .unwrap_or_else(|_| panic!("timed out waiting for {want:?}, last {:?}", publish())); + } + + /// A token the peer granted is not refused as request-only when its session drops before + /// `add` reads the answer, though its grant then reads `None`. + #[tokio::test] + async fn a_grant_survives_its_session_dropping_before_add_returns() { + let version: moq_net::Version = "moq-lite-07-wip".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 = config.init(Default::default()).unwrap().listen().await.unwrap(); + let addr = listener.tcp_local_addr().unwrap(); + + let (drop_tx, drop_rx) = tokio::sync::oneshot::channel::<()>(); + let serving = tokio::spawn(async move { + let mut request = listener.accept().await.expect("accept"); + // Nothing redials: the client stays disconnected once the session drops. + drop(listener); + let mut requests = request.requests(); + let (session, driver) = request.ok().await.unwrap(); + let mut issued = Vec::new(); + for _ in 0..2 { + let request = requests.next().await.expect("token"); + let pattern = match request.token().as_ref() { + b"" => "setup/**", + _ => "extra/**", + }; + issued.push(request.accept(moq_net::auth::Grant { + publish: pattern.parse::().unwrap().into(), + ..Default::default() + })); + } + let _ = drop_rx.await; + drop((session, driver, issued, requests)); + }); + + let client = MoqClient::new(MoqClientConfig { + versions: vec!["moq-lite-07-wip".into()], + bind: Some("127.0.0.1:0".into()), + ..Default::default() + }) + .unwrap(); + let session = tokio::time::timeout(TIMEOUT, client.connect(format!("tcp://{addr}"))) + .await + .expect("connect timed out") + .unwrap(); + let auth = session.auth(); + await_grant(&auth, Some(vec!["setup/**".into()])).await; + + // Presented and granted, but not read until the session is gone. + let mut adding = Box::pin(super::add(auth.handle.clone(), "extra".into())); + assert!(super::poll_now(adding.as_mut()).is_pending()); + await_grant(&auth, Some(vec!["extra/**".into(), "setup/**".into()])).await; + drop_tx.send(()).unwrap(); + await_grant(&auth, None).await; + + let std::task::Poll::Ready(added) = super::poll_now(adding.as_mut()) else { + panic!("add did not resolve"); + }; + let token = added.expect("a granted token refused after its session dropped"); + assert_eq!(token.grant().peek(), None, "no session is live"); + + session.cancel(0); + tokio::time::timeout(TIMEOUT, serving) + .await + .expect("server ended") + .unwrap(); + } +} diff --git a/rs/moq-ffi/src/session.rs b/rs/moq-ffi/src/session.rs index 2dbc6e5995a..9e8c4f514c1 100644 --- a/rs/moq-ffi/src/session.rs +++ b/rs/moq-ffi/src/session.rs @@ -1207,14 +1207,11 @@ impl MoqSession { pub fn auth(&self) -> Arc { Arc::new(MoqAuth::new(match &self.inner { #[cfg(not(target_arch = "wasm32"))] - Inner::Connection(connection) => crate::auth::Handle::Connection(connection.auth()), + Inner::Connection(connection) => connection.auth(), #[cfg(not(target_arch = "wasm32"))] - Inner::Session(session) => crate::auth::Handle::Session(session.auth()), + Inner::Session(session) => session.auth(), #[cfg(target_arch = "wasm32")] - Inner::Dial(dial) => match dial.session() { - Some(session) => crate::auth::Handle::Session(session.auth()), - None => crate::auth::Handle::Dial(dial.clone()), - }, + Inner::Dial(dial) => dial.auth(), })) } diff --git a/rs/moq-ffi/src/session/dial.rs b/rs/moq-ffi/src/session/dial.rs index 1f304165902..4b734eb8d90 100644 --- a/rs/moq-ffi/src/session/dial.rs +++ b/rs/moq-ffi/src/session/dial.rs @@ -50,6 +50,9 @@ pub(crate) struct Dial { bandwidth: moq_net::bandwidth::Allocator, /// The session's receive estimate, forwarded while a consumer holds it. recv: moq_net::bandwidth::Weak, + /// Persistent from before the handshake, so a token added while dialing waits for the + /// session; ended with the dial. + auth: moq_net::auth::Handle, } impl Dial { @@ -63,11 +66,13 @@ impl Dial { let send = moq_net::bandwidth::Producer::new(); let recv = moq_net::bandwidth::Producer::new(); let bandwidth = moq_net::bandwidth::Allocator::new(send.consume()); - let client = client.with_bandwidth(bandwidth.clone()); + let auth = moq_net::auth::Handle::new(); + let client = client.with_bandwidth(bandwidth.clone()).with_auth(auth.clone()); let dial = Self { state: state.clone(), bandwidth, recv: recv.weak(), + auth: auth.clone(), }; let handshake = async move { @@ -77,7 +82,15 @@ impl Dial { .await .map_err(Failure::Handshake) }; - crate::ffi::spawn(run(state, handshake, send, recv)); + crate::ffi::spawn(async move { + let err = run(state, handshake, send, recv).await; + // No session will present the tokens again, so they end with the dial. A token + // reads `Cancel` as its peer ending the grant cleanly. + auth.abort(match err { + moq_net::Error::Cancel => moq_net::Error::Closed, + err => err, + }); + }); dial } @@ -102,11 +115,6 @@ impl Dial { .map(|accepted| accepted.session.clone()) } - /// Wait for the dial to settle: the session, `None` after a local stop, or why it failed. - pub async fn established(&self) -> Result, MoqError> { - Ok(self.settled().await?.map(|accepted| accepted.session)) - } - async fn settled(&self) -> Result, MoqError> { let settled = self .state @@ -187,15 +195,20 @@ impl Dial { pub fn recv_bandwidth(&self) -> moq_net::bandwidth::Consumer { self.recv.consume() } + + /// The auth handle every session of this dial is built on. + pub fn auth(&self) -> moq_net::auth::Handle { + self.auth.clone() + } } -/// Dial, publish the outcome, then drive the session until it closes. +/// Dial, publish the outcome, then drive the session until it closes, returning why it ended. async fn run( state: kio::Producer, handshake: impl Future>, send: moq_net::bandwidth::Producer, recv: moq_net::bandwidth::Producer, -) { +) -> moq_net::Error { let stopped = state.consume(); let stopped = stopped.wait(|state| match state.stop { Some(_) => Poll::Ready(()), @@ -207,18 +220,26 @@ async fn run( result = handshake => result, }; - let Ok(mut published) = state.write() else { return }; + let Ok(mut published) = state.write() else { + return moq_net::Error::Closed; + }; let (session, driver) = match result { Ok(established) if published.stop.is_none() => established, Ok((session, _)) => { // The stop arrived during the handshake's last step. - session.abort(published.stop.clone().unwrap_or(moq_net::Error::Cancel)); + let err = published.stop.clone().unwrap_or(moq_net::Error::Cancel); + session.abort(err.clone()); published.failed = Some(Failure::Stopped); - return; + return err; } Err(failure) => { + let err = match &failure { + Failure::Stopped => published.stop.clone().unwrap_or(moq_net::Error::Cancel), + Failure::Transport(reason) => moq_net::Error::Transport(reason.clone()), + Failure::Handshake(err) => err.clone(), + }; published.failed = Some(failure); - return; + return err; } }; published.session = Some(Accepted { @@ -241,6 +262,7 @@ async fn run( } let _ = send.set(None); let _ = recv.set(None); + session.closed().await } async fn forward_send(source: Option, out: &moq_net::bandwidth::Producer) { diff --git a/rs/moq-net/benches/auth.rs b/rs/moq-net/benches/auth.rs index 83d67f7f674..584fbdefece 100644 --- a/rs/moq-net/benches/auth.rs +++ b/rs/moq-net/benches/auth.rs @@ -184,7 +184,7 @@ fn renewal(c: &mut Criterion) { let (session, _, requests) = &mut publishers[0]; let handle = session.auth(); let auths = runtime - .block_on(async { [handle.add("a").await.unwrap(), handle.add("b").await.unwrap()] }); + .block_on(async { [handle.add("a").await.unwrap().0, handle.add("b").await.unwrap().0] }); let broadcasts: Vec<_> = (0..routes) .map(|route| origin.create_broadcast(format!("room/{route}").as_str()).unwrap()) .collect(); diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index 181e4443424..92bab3af116 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -9,6 +9,11 @@ //! A [`Route`](crate::origin::Route) can name it, so the request advertising the route //! carries it too, and swapping it renews that request in place. //! +//! A handle passed to `Client::with_auth` outlives its sessions: each session built on it +//! presents every token it holds, so a route naming one of its [`Auth`]s keeps it across a +//! reconnect. Only the peer's verdict ends such a token; a session ending leaves it for the +//! next. It backs one active session at a time, plus the ones draining after a GOAWAY. +//! //! The other direction, answering the peer's tokens, is automatic: the session //! grants what its own origin handles allow. A server that verifies tokens itself //! takes the client's from `server::Handshake::requests` before accepting, and then @@ -33,6 +38,7 @@ use std::{ collections::{BTreeMap, VecDeque}, task::Poll, + time::Duration, }; use bytes::Bytes; @@ -62,6 +68,15 @@ macro_rules! ready_or { }; } +/// How a peer accepted a token [`Handle::add`] presented. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Answer { + /// Granted on its AUTH stream: its grant joins the union. + Granted, + /// Taken only for the requests that name it, on moq-transport without AUTH. + Requests, +} + /// What a peer lets this side do, in this side's own paths. /// /// The paths are relative to the session's root, the same paths the session @@ -130,44 +145,83 @@ fn earliest(a: Option, b: Option) -> Option { } } -/// The shared state behind every auth handle of one session. +/// How long a token refused for a reason that may pass waits before its session presents it +/// again, doubling on each refusal up to [`RETRY_MAX`]. +const RETRY_INITIAL: Duration = Duration::from_secs(1); +const RETRY_MAX: Duration = Duration::from_secs(30); + +/// Whether a refusal is the acceptor failing to decide rather than its verdict on the +/// token, such as an unreachable auth server or a full verification queue. +fn transient(err: &Error) -> bool { + matches!( + err, + Error::Session(SessionError::Internal | SessionError::TooManyRequests | SessionError::Timeout) + ) +} + +/// The shared state behind one [`Handle`]: the tokens it presents, and every session +/// it backs. #[derive(Default)] pub(crate) struct State { + /// Created by a session for itself, so it ends with that session. + bound: bool, + /// Set once the handle ends; every waiter resolves with it. + closed: Option, + next_id: u64, + /// Every token the application added that it has not withdrawn. + tokens: BTreeMap, + next_link: u64, + /// Every session built on this handle that has not dropped, oldest first. + links: BTreeMap, + /// What the peer's connection credential lets it do, in the peer's terms: `None` (only + /// the origin handles bound it) until [`Handle::authorize`] sets it; the grant it was + /// last called with. + limit: Option, + next_issue: u64, + acceptor: Acceptor, +} + +/// A token the application added, presented on every session of its handle. +struct Token { + value: Bytes, + /// The first verdict any session's peer gave: how it accepted the token, or why it ended. + answered: Option>, + /// Why the token ended for good: a peer's refusal or revocation, or the handle's end. + ended: Option, +} + +/// One session's side of a handle. +struct Attached { /// Whether the negotiated version carries AUTH at all. supported: bool, /// Whether a request can carry a token itself (moq-transport), so an auth needs no /// AUTH stream to be named. requests: bool, - /// Set once the session ends; every waiter resolves with it. + /// Set once the session ends. closed: Option, - next_id: u64, - /// Every token this side presented that has not ended. - tokens: BTreeMap, + /// The session's GOAWAY: once either side sends one, the session is draining. + goaway: Option>, + /// Every token this session presented that has not been withdrawn. + slots: BTreeMap, /// Tokens waiting for the driver to open their stream. opening: VecDeque, /// The union of every open token's grant, `None` until the peer first replies. union: Option, /// The peer replied to some token, so the union is known even when empty. replied: bool, - /// What the peer's connection credential lets it do, in the peer's terms: `None` (only - /// the origin handles bound it) until [`Handle::authorize`] sets it; the grant it was - /// last called with. - limit: Option, /// The grants the application issued to the peer's AUTH tokens, by stream, in the /// peer's terms. A token on a single request never joins them. issued: BTreeMap, - next_issue: u64, /// The session's scope for the peer: the limit and every issued grant, `None` while the /// limit is. scope: Option, /// Bumped whenever the union or the scope changes, so the session's per-stream /// gates can skip re-matching paths on every wakeup. epoch: u64, - acceptor: Acceptor, } -/// One presented token. -pub(crate) struct Slot { +/// One token as one session presents it. +struct Slot { token: Bytes, /// Presented by the session itself at setup, so enforcement waits on it. setup: bool, @@ -175,15 +229,39 @@ pub(crate) struct Slot { grant: Option, /// Handed to the driver to open its AUTH stream, which then owns ending it. streamed: bool, - /// The first reply: `Ok` for an AUTH_OK, the refusal otherwise. + /// The latest reply: `Ok` for an AUTH_OK, the refusal otherwise. answered: Option>, - /// Why the token ended, once it has. + /// The peer refused the current attempt with an AUTH_ERROR. + refused: bool, + /// The delay before the last retry, which the next one doubles. + retry: Option, + /// Why the token ended on this session, once it has. ended: Option, /// The last [`Auth`] clone dropped: the driver closes the stream. withdrawn: bool, } -/// Who answers the peer's tokens. Decided once, when the driver first runs. +impl Slot { + fn new(token: Bytes, setup: bool, answered: Option>) -> Self { + let ended = match &answered { + Some(Err(err)) => Some(err.clone()), + _ => None, + }; + Self { + token, + setup, + grant: None, + streamed: false, + answered, + refused: false, + retry: None, + ended, + withdrawn: false, + } + } +} + +/// Who answers the peer's tokens. Decided once, when a driver first runs. #[derive(Default)] enum Acceptor { /// Nobody asked yet; the app may still take [`Handle::requests`]. @@ -195,9 +273,83 @@ enum Acceptor { Default, } -impl State { +impl Attached { + fn new(supported: bool, requests: bool, limit: Option<&Grant>) -> Self { + let mut attached = Self { + supported, + requests, + closed: None, + goaway: None, + slots: BTreeMap::new(), + opening: VecDeque::new(), + union: None, + replied: false, + issued: BTreeMap::new(), + scope: None, + epoch: 0, + }; + attached.rescope(limit); + attached + } + + /// Whether the session still takes new work: open, with no GOAWAY sent or received. + fn active(&self) -> bool { + self.closed.is_none() && !self.goaway.as_ref().is_some_and(|goaway| goaway.draining()) + } + + /// Present the handle's token `id` on this session, returning the answer when the + /// session settles it at once: `Ok` when only a request can carry it, so nothing will + /// answer, and [`Error::Unsupported`] when nothing can. + /// + /// A draining session takes no new tokens, so its verdict never ends one its successor + /// carries. + fn offer(&mut self, id: u64, token: &Bytes) -> Option> { + if !self.active() { + return None; + } + let answered = match (self.supported, self.requests) { + (true, _) => None, + (false, true) => Some(Ok(())), + (false, false) => Some(Err(Error::Unsupported)), + }; + self.slots.insert(id, Slot::new(token.clone(), false, answered.clone())); + if self.supported { + self.opening.push_back(id); + } + answered + } + + /// End token `id` on this session, if it is still live here. + fn end(&mut self, id: u64, err: &Error) { + let Some(slot) = self.slots.get_mut(&id) else { + return; + }; + if slot.ended.is_some() { + return; + } + slot.grant = None; + slot.answered.get_or_insert(Err(err.clone())); + slot.ended = Some(err.clone()); + self.epoch += 1; + self.recompute(); + } + + /// The last [`Auth`] clone for token `id` dropped. + fn withdraw(&mut self, id: u64) { + let Some(slot) = self.slots.get_mut(&id) else { + return; + }; + slot.withdrawn = true; + // Already over, or no stream whose close would end it: nothing left to close. + if slot.ended.is_some() || !slot.streamed { + self.opening.retain(|opening| *opening != id); + self.slots.remove(&id); + self.recompute(); + } + } + fn recompute(&mut self) { - let mut granted = self.tokens.values().filter_map(|slot| slot.grant.as_ref()).peekable(); + let mut granted = self.slots.values().filter_map(|slot| slot.grant.as_ref()).peekable(); if granted.peek().is_none() && !self.replied { return; } @@ -208,23 +360,15 @@ impl State { } } - /// Re-derive the scope from the limit and the issued grants. - fn rescope(&mut self) { - let scope = self - .limit - .as_ref() - .map(|limit| Grant::union(std::iter::once(limit).chain(self.issued.values()))); + /// Re-derive the scope from the handle's limit and the issued grants. + fn rescope(&mut self, limit: Option<&Grant>) { + let scope = limit.map(|limit| Grant::union(std::iter::once(limit).chain(self.issued.values()))); if self.scope != scope { self.scope = scope; self.epoch += 1; } } - /// The error handed to anything waiting on a session that has ended. - fn closed(&self) -> Option { - self.closed.clone() - } - /// The union's and the scope's patterns for `direction`, each `None` while /// unrestricted. fn parts(&self, direction: Direction) -> (Option<&Patterns>, Option<&Patterns>) { @@ -258,6 +402,58 @@ impl State { } } +impl State { + /// The session the handle backs now: the newest one still open. + fn live(&self) -> Option<&Attached> { + 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`, + /// and a refusal is the verdict that ends the token on every session. + fn answer(&mut self, id: u64, answer: Result) { + match answer { + Ok(answer) => { + if let Some(token) = self.tokens.get_mut(&id) { + token.answered.get_or_insert(Ok(answer)); + } + } + Err(err) => self.end_token(id, err), + } + } + + /// End token `id` for good, closing its stream on every session. + fn end_token(&mut self, id: u64, err: Error) { + let Some(token) = self.tokens.get_mut(&id) else { + return; + }; + if token.ended.is_some() { + return; + } + token.answered.get_or_insert(Err(err.clone())); + token.ended = Some(err.clone()); + for link in self.links.values_mut() { + link.end(id, &err); + } + } + + /// End the handle, returning the application's queue for the caller to close once the + /// lock is released, since refusing its tokens takes the lock again. + fn abort(&mut self, err: Error) -> Option { + if self.closed.is_some() { + return None; + } + self.closed = Some(err.clone()); + let ids: Vec = self.tokens.keys().copied().collect(); + for id in ids { + self.end_token(id, err.clone()); + } + match &self.acceptor { + Acceptor::App(queue) => Some(queue.clone()), + _ => None, + } + } +} + /// What one direction of a session allows: the grant the peer gave this side (the /// union of its tokens) and the limit this side set on the peer, each `None` /// while unrestricted. @@ -296,119 +492,93 @@ impl Permit { } } -/// The session's auth handle, returned by `Session::auth`. +/// The tokens this side presents and the grant they earn, across every session built on it. /// -/// Cheap to clone; every clone shares the session's tokens. -#[derive(Clone)] +/// A session made without one creates its own, returned by `Session::auth`, which ends +/// with that session. Pass one to `Client::with_auth` instead and it outlives its +/// sessions: each presents every token the handle holds, so an [`Auth`] it issued keeps +/// riding a route's announcements across a reconnect. It backs one active session at a +/// time, plus the ones draining after a GOAWAY. +/// +/// Cheap to clone; every clone shares the same tokens. +#[derive(Clone, Default)] pub struct Handle { state: kio::Shared, } impl Handle { - /// A handle for a session that speaks AUTH (`supported`) or never will. - pub(crate) fn new(supported: bool) -> Self { - Self { - state: kio::Shared::new(State { - supported, - ..Default::default() - }), - } + /// A handle backing no session yet, for `Client::with_auth`. + pub fn new() -> Self { + Self::default() } - /// A moq-transport handle: like [`new`](Self::new), but a request can carry a token - /// itself, so [`add`](Self::add) succeeds without AUTH. - pub(crate) fn ietf(supported: bool) -> Self { - Self { - state: kio::Shared::new(State { - supported, - requests: true, - ..Default::default() - }), - } - } - - /// Whether this side speaks AUTH: false on a version without it, on a side that does - /// not offer it, and once the peer turns out not to. - pub(crate) fn supported(&self) -> bool { - self.state.lock().supported - } - - /// The union of every grant this side holds: `None` until the peer first - /// answers a token (with a grant or a refusal), and forever on a version - /// without AUTH. + /// The union of every grant this side holds on the live session: `None` until its peer + /// first answers a token (with a grant or a refusal), while no session is live, and + /// forever on a version without AUTH. pub fn grant(&self) -> Watch { Watch::new(self.state.clone(), Selector::Union) } - /// Present another token, resolving once the peer answers it. + /// Present another token on every session of this handle, resolving once a peer accepts it + /// with how it did. /// - /// The grant joins the union while any clone of the returned [`Auth`] lives. 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 [`Auth`] 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. On moq-transport + /// without AUTH there is no answer to wait for: it resolves at once with + /// [`Answer::Requests`] and a grant that stays unknown, and 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 session's close. + /// 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(), false)?; - kio::wait(|waiter| auth.poll_answered(waiter)).await?; - Ok(auth) + pub async fn add(&self, token: impl Into) -> Result<(Auth, Answer)> { + let auth = self.present(token.into())?; + let answer = kio::wait(|waiter| auth.poll_answered(waiter)).await?; + Ok((auth, answer)) } - /// Queue a token for the driver to present. - pub(crate) fn present(&self, token: Bytes, setup: bool) -> Result { - let mut state = self.state.lock(); - if !state.supported && !state.requests { - return Err(Error::Unsupported); + /// Register a token and queue it on every open session. + pub(crate) fn present(&self, token: Bytes) -> Result { + let mut guard = self.state.lock(); + let state = &mut *guard; + if let Some(err) = &state.closed { + return Err(err.clone()); } - if let Some(err) = state.closed() { - return Err(err); + // A draining session takes no new tokens, and a handle of its own has no successor. + if state.bound && !state.links.values().any(Attached::active) { + return Err(Error::GoingAway); } let id = state.next_id; state.next_id += 1; - // Without AUTH only a request presents it, so nothing will answer. - let answered = (!state.supported).then_some(Ok(())); state.tokens.insert( id, - Slot { - token, - setup, - grant: None, - streamed: false, - answered, + Token { + value: token.clone(), + answered: None, ended: None, - withdrawn: false, }, ); - if state.supported { - state.opening.push_back(id); + let answers: Vec<_> = state + .links + .values_mut() + .filter_map(|link| link.offer(id, &token)) + .collect(); + for answer in answers { + state.answer(id, answer.map(|()| Answer::Requests)); } Ok(Auth { presented: std::sync::Arc::new(Presented { state: self.state.clone(), id, + link: None, }), }) } - /// The token a request carries for `auth`, or `None` when the request goes out unnamed - /// under the session's scope: `auth` came from another handle, it ended, or requests - /// carry no token on this version. - pub(crate) fn token(&self, auth: &Auth) -> Option { - if !auth.presented.state.same_channel(&self.state) { - return None; - } - let state = self.state.read(); - if !state.requests { - return None; - } - let slot = state.tokens.get(&auth.presented.id)?; - slot.ended.is_none().then(|| slot.token.clone()) - } - /// Answer every token the peer presents, instead of the default acceptor; see /// [`Handshake::requests`](crate::server::Handshake::requests). /// @@ -431,6 +601,51 @@ impl Handle { Requests { queue } } + /// Authorize the peer's connection credential for `grant` on every session of this + /// handle, in the session's own paths. Calling it again re-authorizes, narrower or + /// wider, replacing the last grant. The session's origin handles still bound it, so it + /// never grants more than they allow. + /// + /// The session's scope for the peer is this grant and every grant issued to one of its + /// AUTH tokens through `server::Handshake::requests`; a session never authorized is bound + /// only by its origin handles. A request that carries its own token is decided by that + /// token instead, inside the scope or not. + /// + /// A narrower scope ends what falls outside at once, with [`Error::Unauthorized`] + /// where it has a reader: announcements to the peer retract, its new requests are + /// refused, its subscriptions reset, and the broadcasts it published abort. A wider + /// one brings back what the old scope held back: announcements to the peer are made + /// again, and so are the peer's announcements it withheld from the origin, and new + /// requests are accepted. The rest of the session carries on. It works on every + /// version, since the session enforces it without the peer's help. + /// + /// When the session answers the peer's connection credential itself, the peer is + /// told the grant, within what the origin handles allow, `expires` included. An + /// application answering tokens through `server::Handshake::requests` updates its own + /// [`Issued`] grants. + pub fn authorize(&self, grant: &Grant) { + let mut guard = self.state.lock(); + let state = &mut *guard; + if state.limit.as_ref() == Some(grant) { + return; + } + state.limit = Some(grant.clone()); + for link in state.links.values_mut() { + link.epoch += 1; + link.rescope(state.limit.as_ref()); + } + } + + /// End the handle with `err`: every token ends, its sessions stop presenting them, and + /// the peer's queued tokens are refused. A handle a session created for itself ends + /// with that session. + pub fn abort(&self, err: Error) { + let queue = self.state.lock().abort(err); + if let Some(queue) = queue { + queue.close(); + } + } + /// Ask the acceptor to verify a token that rode on one of the peer's requests. /// /// The token reaches it through [`requests`](Self::requests) like any other. The grant it @@ -459,7 +674,7 @@ impl Handle { /// Decide who answers the peer's tokens: the app if it took the requests, /// otherwise the session itself (`None`). Idempotent. - pub(crate) fn acceptor(&self) -> Option { + fn acceptor(&self) -> Option { let mut state = self.state.lock(); match &state.acceptor { Acceptor::App(queue) => Some(queue.clone()), @@ -470,38 +685,248 @@ impl Handle { } } } +} + +/// One session's side of a [`Handle`]: the tokens its driver presents, the peer's tokens +/// it answers, and the grants it enforces. +/// +/// Cheap to clone; the session leaves its handle when the last clone drops. +#[derive(Clone)] +pub(crate) struct Link { + handle: Handle, + attached: std::sync::Arc, +} + +/// Removes a session from its handle once nothing holds its [`Link`]. +struct Detach { + state: kio::Shared, + id: u64, +} + +impl Drop for Detach { + fn drop(&mut self) { + let queue = { + let mut state = self.state.lock(); + state.links.remove(&self.id); + // Dropped without closing, such as a driver dropped mid-session. + match state.bound { + true => state.abort(Error::Cancel), + false => None, + } + }; + if let Some(queue) = queue { + queue.close(); + } + } +} + +impl Link { + /// A session that speaks AUTH (`supported`) or never will, on a handle of its own. + pub(crate) fn new(supported: bool) -> Self { + Self::bound(supported, false) + } + + /// A moq-transport session: like [`new`](Self::new), but a request can carry a token + /// itself, so a token needs no AUTH to be presented. + pub(crate) fn ietf(supported: bool) -> Self { + Self::bound(supported, true) + } + + fn bound(supported: bool, requests: bool) -> Self { + let handle = Handle { + state: kio::Shared::new(State { + bound: true, + ..Default::default() + }), + }; + Self::attach(&handle, supported, requests).expect("a fresh handle backs no session") + } + + /// Build a session on `handle`, the moq-transport flavor when `requests`. + /// + /// # Errors + /// + /// [`Error::Duplicate`] while the handle backs another active session, or the + /// handle's end. + pub(crate) fn attach(handle: &Handle, supported: bool, requests: bool) -> Result { + let mut guard = handle.state.lock(); + let state = &mut *guard; + if let Some(err) = &state.closed { + return Err(err.clone()); + } + if state.links.values().any(Attached::active) { + return Err(Error::Duplicate); + } + let id = state.next_link; + state.next_link += 1; + let mut link = Attached::new(supported, requests, state.limit.as_ref()); + let answers: Vec<_> = state + .tokens + .iter() + .filter(|(_, token)| token.ended.is_none()) + .filter_map(|(token_id, token)| Some((*token_id, link.offer(*token_id, &token.value)?))) + .collect(); + state.links.insert(id, link); + for (token_id, answer) in answers { + state.answer(token_id, answer.map(|()| Answer::Requests)); + } + Ok(Self { + handle: handle.clone(), + attached: std::sync::Arc::new(Detach { + state: handle.state.clone(), + id, + }), + }) + } + + /// The handle this session is built on. + pub(crate) fn handle(&self) -> &Handle { + &self.handle + } + + fn state(&self) -> &kio::Shared { + &self.handle.state + } + + fn id(&self) -> u64 { + self.attached.id + } + + /// Run `f` on this session's side of the handle. + fn with(&self, f: impl FnOnce(&mut Attached) -> R) -> R { + self.with_state(|state, id| f(attached(&mut state.links, id))) + } + + /// Run `f` on the handle's state, with this session's id. + fn with_state(&self, f: impl FnOnce(&mut State, u64) -> R) -> R { + f(&mut self.state().lock(), self.id()) + } + + /// Read this session's side of the handle, and the handle's state. + fn read(&self, f: impl FnOnce(&Attached, &State) -> R) -> R { + let state = self.state().read(); + let link = state + .links + .get(&self.id()) + .expect("a link outlives its session's state"); + f(link, &state) + } + + /// Retire the session to draining once `goaway` is sent or received, freeing the + /// handle for a replacement. + pub(crate) fn drain_with(&self, goaway: std::sync::Arc) { + self.with(|link| link.goaway = Some(goaway)); + } + + /// Whether the session still takes new work: open, with no GOAWAY sent or received. + pub(crate) fn active(&self) -> bool { + self.read(|link, _| link.active()) + } + + /// Whether this side speaks AUTH: false on a version without it, on a side that does + /// not offer it, and once the peer turns out not to. + pub(crate) fn supported(&self) -> bool { + self.read(|link, _| link.supported) + } + + /// Present a token: the session's own credential at setup, presented on this session + /// 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); + } + let id = self.with_state(|state, link| { + let id = state.next_id; + let link = attached(&mut state.links, link); + if !link.supported && !link.requests { + return Err(Error::Unsupported); + } + if let Some(err) = &link.closed { + return Err(err.clone()); + } + // Without AUTH only a request presents it, so nothing will answer. + let answered = (!link.supported).then_some(Ok(())); + link.slots.insert(id, Slot::new(token, true, answered)); + if link.supported { + link.opening.push_back(id); + } + state.next_id += 1; + Ok(id) + })?; + Ok(Auth { + presented: std::sync::Arc::new(Presented { + state: self.state().clone(), + id, + link: Some(self.id()), + }), + }) + } + + /// The token a request carries for `auth`, or `None` when the request goes out unnamed + /// under the session's scope: `auth` came from another handle, it ended here, or + /// requests carry no token on this version. + pub(crate) fn token(&self, auth: &Auth) -> Option { + if !auth.presented.state.same_channel(self.state()) { + return None; + } + self.read(|link, _| { + if !link.requests { + return None; + } + let slot = link.slots.get(&auth.presented.id)?; + slot.ended.is_none().then(|| slot.token.clone()) + }) + } + + /// Ask the acceptor to verify a token that rode on one of the peer's requests; see + /// [`Handle::verify_request`]. + pub(crate) fn verify_request(&self, token: crate::setup::Token, hold: Option) -> RequestVerdict { + self.handle.verify_request(token, hold) + } + + /// Decide who answers the peer's tokens: the app if it took the requests, + /// otherwise the session itself (`None`). Idempotent. + pub(crate) fn acceptor(&self) -> Option { + self.handle.acceptor() + } /// The next token to open a stream for, or `Ready(None)` once the session ends. pub(crate) fn poll_opening(&self, waiter: &kio::Waiter) -> Poll> { - let mut state = ready_or!(self.state.poll(waiter, |state| { - match state.opening.is_empty() && state.closed.is_none() { - true => Poll::Pending, - false => Poll::Ready(()), + let id = self.id(); + loop { + let mut guard = ready_or!(self.state().poll(waiter, |state| { + match state.links.get(&id) { + Some(link) if link.opening.is_empty() && link.closed.is_none() => Poll::Pending, + _ => Poll::Ready(()), + } + })); + let Some(link) = guard.links.get_mut(&id) else { + return Poll::Ready(None); + }; + if link.closed.is_some() { + return Poll::Ready(None); } - })); - if state.closed.is_some() { - return Poll::Ready(None); - } - let id = state.opening.pop_front().expect("checked non-empty"); - let token = state.tokens.get_mut(&id).map(|slot| { - slot.streamed = true; - slot.token.clone() - }); - match token { - Some(token) => Poll::Ready(Some((id, token))), - // Withdrawn before its stream opened: nothing to present. - None => { - drop(state); - self.poll_opening(waiter) + let token = link.opening.pop_front().expect("checked non-empty"); + // Withdrawn or ended before its stream opened: nothing to present. + if let Some(slot) = link.slots.get_mut(&token) + && slot.ended.is_none() + { + slot.streamed = true; + slot.refused = false; + return Poll::Ready(Some((token, slot.token.clone()))); } } } - /// Whether the token's handle dropped, so its stream should close. + /// Whether the token's stream should close: its handle dropped, or it ended, here or + /// for good on another session. pub(crate) fn poll_withdrawn(&self, id: u64, waiter: &kio::Waiter) -> Poll<()> { - let _ = ready_or!(self.state.poll(waiter, |state| match state.tokens.get(&id) { - Some(slot) if !slot.withdrawn => Poll::Pending, - _ => Poll::Ready(()), + let link = self.id(); + let _ = ready_or!(self.state().poll(waiter, |state| { + match state.links.get(&link).and_then(|link| link.slots.get(&id)) { + Some(slot) if !slot.withdrawn && slot.ended.is_none() => Poll::Pending, + _ => Poll::Ready(()), + } })); Poll::Ready(()) } @@ -516,50 +941,141 @@ impl Handle { subscribe = %list(&grant.subscribe), "auth granted" ); - let mut state = self.state.lock(); - let Some(slot) = state.tokens.get_mut(&id) else { - return; - }; - slot.grant = Some(grant); - slot.answered.get_or_insert(Ok(())); - state.replied = true; - state.recompute(); + self.with_state(|state, link| { + let link = attached(&mut state.links, link); + let Some(slot) = link.slots.get_mut(&id) else { + return; + }; + if slot.ended.is_some() { + return; + } + slot.grant = Some(grant); + slot.answered = Some(Ok(())); + slot.retry = None; + link.replied = true; + link.recompute(); + state.answer(id, Ok(Answer::Granted)); + }); } /// Record an AUTH_ERROR for the token: a reply that grants nothing, so a refused /// setup token leaves an empty union rather than an unknown (unrestricted) one. - /// The driver still ends the token with [`ended`](Self::ended). + /// The driver then calls [`retry`](Self::retry), and [`ended`](Self::ended) unless it + /// presents the token again. pub(crate) fn refused(&self, id: u64) { - let mut state = self.state.lock(); - if state.tokens.contains_key(&id) { - state.replied = true; - } + self.with(|link| { + if let Some(slot) = link.slots.get_mut(&id) { + slot.refused = true; + link.replied = true; + } + }); + } + + /// The delay before presenting a refused token again, when the refusal `err` may pass + /// and the token is still wanted here; `None` when the driver should end it instead. + pub(crate) fn retry(&self, id: u64, err: &Error) -> Option { + self.with(|link| { + if link.closed.is_some() || !transient(err) { + return None; + } + let slot = link.slots.get_mut(&id)?; + if !slot.refused || slot.withdrawn || slot.ended.is_some() { + return None; + } + slot.refused = false; + slot.streamed = false; + slot.grant = None; + // Unanswered again, as on its first presentation: a request or publication the + // token might cover waits for the retry's verdict rather than being judged without it. + slot.answered = None; + let delay = slot.retry.map_or(RETRY_INITIAL, |delay| (delay * 2).min(RETRY_MAX)); + slot.retry = Some(delay); + link.epoch += 1; + link.recompute(); + Some(delay) + }) + } + + /// The token to present again once a [`retry`](Self::retry) delay passes. + /// + /// # Errors + /// + /// [`Error::GoingAway`] once the session is draining, so a successor presents it instead + /// and a handle of the session's own never reads it as the peer's clean close, or + /// [`Error::Cancel`] when it is no longer wanted here. + pub(crate) fn reopen(&self, id: u64) -> Result { + self.with(|link| { + if !link.active() { + return Err(Error::GoingAway); + } + match link.slots.get_mut(&id) { + Some(slot) if !slot.withdrawn && slot.ended.is_none() => { + slot.streamed = true; + Ok(slot.token.clone()) + } + _ => Err(Error::Cancel), + } + }) } - /// End the token: refused or revoked by the peer, withdrawn by us, or its - /// stream lost. A token that was never answered records `err` as its answer. + /// End the token on this session: refused or revoked by the peer, withdrawn by us, or + /// its stream lost. A token that was never answered records `err` as its answer. The + /// peer's verdict ends one of the handle's tokens on every session; anything else + /// leaves it for the next session to present, except on a handle of the session's own. pub(crate) fn ended(&self, id: u64, err: Error) { - let mut state = self.state.lock(); - let Some(slot) = state.tokens.get_mut(&id) else { - return; - }; - slot.grant = None; - slot.answered.get_or_insert(Err(err.clone())); - slot.ended = Some(err); - // The handle is gone and the token is over: nothing will read the slot again. - if slot.withdrawn { - state.tokens.remove(&id); - } - // Even with the union unchanged, a request carrying this token must drop it. - state.epoch += 1; - state.recompute(); + self.with_state(|state, link_id| { + // A draining session's verdict is stale while an active session still presents the + // token: that session's own answer decides, even mid-retry with no grant right now. + let superseded = !attached(&mut state.links, link_id).active() + && state.links.iter().any(|(other, link)| { + *other != link_id + && link.active() && link + .slots + .get(&id) + .is_some_and(|slot| slot.ended.is_none() && !slot.withdrawn) + }); + // A handle of the session's own has no next session to present it, so any end is final. + let bound = state.bound; + let link = attached(&mut state.links, link_id); + let Some(slot) = link.slots.get_mut(&id) else { + return; + }; + let verdict = !slot.setup + && !superseded + && link.closed.is_none() + && !slot.withdrawn + && slot.ended.is_none() + && (bound + || slot.refused + || matches!(err, Error::Unsupported) + // The peer ended the grant without revoking it. + || (matches!(err, Error::Cancel) && slot.grant.is_some())); + slot.grant = None; + slot.answered.get_or_insert(Err(err.clone())); + slot.ended.get_or_insert(err.clone()); + // The handle is gone and the token is over: nothing will read the slot again. + if slot.withdrawn { + link.slots.remove(&id); + } + // Even with the union unchanged, a request carrying this token must drop it. + link.epoch += 1; + link.recompute(); + if verdict { + state.end_token(id, err); + } + }); } /// Resolve once every token the session presented at setup has its first /// reply, so enforcement never waits on a token the app added later. pub(crate) fn poll_setup_answered(&self, waiter: &kio::Waiter) -> Poll<()> { - let _ = ready_or!(self.state.poll(waiter, |state| { - match state.tokens.values().any(|slot| slot.setup && slot.answered.is_none()) { + let id = self.id(); + let _ = ready_or!(self.state().poll(waiter, |state| { + let pending = state + .links + .get(&id) + .is_some_and(|link| link.slots.values().any(|slot| slot.setup && slot.answered.is_none())); + match pending { true => Poll::Pending, false => Poll::Ready(()), } @@ -569,11 +1085,15 @@ impl Handle { /// Wait only when a token already presented might widen the grant for this path. fn poll_presented(&self, direction: Direction, path: &str, waiter: &kio::Waiter) -> Poll<()> { - let _ = ready_or!(self.state.poll(waiter, |state| { - let (granted, _) = state.parts(direction); + let id = self.id(); + let _ = ready_or!(self.state().poll(waiter, |state| { + let Some(link) = state.links.get(&id) else { + return Poll::Ready(()); + }; + let (granted, _) = link.parts(direction); if granted.is_some_and(|grant| grant.matches(path)) - || !state - .tokens + || !link + .slots .values() .any(|slot| !slot.token.is_empty() && slot.answered.is_none() && !slot.withdrawn) { @@ -585,73 +1105,47 @@ impl Handle { Poll::Ready(()) } - /// Authorize the peer's connection credential for `grant` on this session, in the - /// session's own paths. Calling it again re-authorizes, narrower or wider, replacing the - /// last grant. The session's origin handles still bound it, so it never grants more than - /// they allow. - /// - /// The session's scope for the peer is this grant and every grant issued to one of its - /// AUTH tokens through `server::Handshake::requests`; a session never authorized is bound - /// only by its origin handles. A request that carries its own token is decided by that - /// token instead, inside the scope or not. - /// - /// A narrower scope ends what falls outside at once, with [`Error::Unauthorized`] - /// where it has a reader: announcements to the peer retract, its new requests are - /// refused, its subscriptions reset, and the broadcasts it published abort. A wider - /// one brings back what the old scope held back: announcements to the peer are made - /// again, and so are the peer's announcements it withheld from the origin, and new - /// requests are accepted. The rest of the session carries on. It works on every - /// version, since the session enforces it without the peer's help. - /// - /// When the session answers the peer's connection credential itself, the peer is - /// told the grant, within what the origin handles allow, `expires` included. An - /// application answering tokens through `server::Handshake::requests` updates its own - /// [`Issued`] grants. - pub fn authorize(&self, grant: &Grant) { - let mut state = self.state.lock(); - if state.limit.as_ref() != Some(grant) { - state.limit = Some(grant.clone()); - state.epoch += 1; - state.rescope(); - } - } - /// Record the grant an application issued to the peer's AUTH token `id`, or forget it with /// `None`, resizing the scope. fn issue(&self, id: u64, grant: Option) { - let mut state = self.state.lock(); - match grant { - Some(grant) => state.issued.insert(id, grant), - None => state.issued.remove(&id), - }; - state.rescope(); + self.with_state(|state, link| { + let link = attached(&mut state.links, link); + match grant { + Some(grant) => link.issued.insert(id, grant), + None => link.issued.remove(&id), + }; + link.rescope(state.limit.as_ref()); + }); } /// The union once it changed since `epoch` last saw it, advancing `epoch`. It /// only ever changes into `Some`: no answer yet is the initial state. pub(crate) fn poll_union(&self, epoch: &mut u64, waiter: &kio::Waiter) -> Poll> { let state = ready_or!(self.poll_epoch(*epoch, waiter)); - *epoch = state.epoch; - Poll::Ready(state.union.clone()) + let link = &state.links[&self.id()]; + *epoch = link.epoch; + Poll::Ready(link.union.clone()) } /// What `direction` allows once it may have changed since `epoch` last saw it, /// advancing `epoch`. pub(crate) fn poll_permit(&self, direction: Direction, epoch: &mut u64, waiter: &kio::Waiter) -> Poll { let state = ready_or!(self.poll_epoch(*epoch, waiter)); - *epoch = state.epoch; - Poll::Ready(state.permit(direction)) + let link = &state.links[&self.id()]; + *epoch = link.epoch; + Poll::Ready(link.permit(direction)) } /// The limit once it may have changed since `epoch` last saw it, advancing `epoch`. pub(crate) fn poll_limit(&self, epoch: &mut u64, waiter: &kio::Waiter) -> Poll> { let state = ready_or!(self.poll_epoch(*epoch, waiter)); - *epoch = state.epoch; + *epoch = state.links[&self.id()].epoch; Poll::Ready(state.limit.clone()) } fn poll_epoch(&self, seen: u64, waiter: &kio::Waiter) -> Poll> { - self.state.poll(waiter, |state| match state.epoch != seen { + let id = self.id(); + self.state().poll(waiter, |state| match state.links[&id].epoch != seen { true => Poll::Ready(()), false => Poll::Pending, }) @@ -661,7 +1155,7 @@ impl Handle { /// while it is `None`, before an answer or on a version without AUTH) and the /// limit both. pub(crate) fn allows(&self, direction: Direction, path: &str) -> bool { - self.state.read().allows(direction, path) + self.read(|link, _| link.allows(direction, path)) } /// Whether a request token decides the request carrying it, rather than the session's @@ -671,87 +1165,116 @@ impl Handle { /// that neither scopes the peer nor answers tokens has nothing to check one against, and /// admits as it would without it. pub(crate) fn token_decides(&self, direction: Direction) -> bool { - let state = self.state.read(); - let (_, scope) = state.parts(direction); - scope.is_some() || matches!(state.acceptor, Acceptor::App(_)) + self.read(|link, state| { + let (_, scope) = link.parts(direction); + scope.is_some() || matches!(state.acceptor, Acceptor::App(_)) + }) } /// Whether the peer's grant alone allows `direction` at `path`, allowing everything /// while it is `None`: what bounds this side whatever authorized the request. pub(crate) fn within_grant(&self, direction: Direction, path: &str) -> bool { - let state = self.state.read(); - let (granted, _) = state.parts(direction); - granted.is_none_or(|granted| granted.matches(path)) + self.read(|link, _| { + let (granted, _) = link.parts(direction); + granted.is_none_or(|granted| granted.matches(path)) + }) } /// Whether the scope alone allows `direction` at `path`: for a route the peer /// offers us, which our own grant does not decide. pub(crate) fn within_limit(&self, direction: Direction, path: &str) -> bool { - let state = self.state.read(); - let (_, scope) = state.parts(direction); - scope.is_none_or(|scope| scope.matches(path)) + self.read(|link, _| { + let (_, scope) = link.parts(direction); + scope.is_none_or(|scope| scope.matches(path)) + }) } /// The peer turned out not to negotiate AUTH, leaving the union unknown. On /// moq-transport every token we presented stays, with an unknown grant, for the - /// requests that name it; elsewhere each fails as unsupported. The peer's tokens can - /// still ride its requests, so their queue stays open. + /// requests that name it; elsewhere each fails as unsupported, which ends the handle's + /// tokens for good. The peer's tokens can still ride its requests, so their queue + /// stays open. pub(crate) fn unsupported(&self) { - let mut state = self.state.lock(); - state.supported = false; - state.opening.clear(); - let requests = state.requests; - for slot in state.tokens.values_mut() { - if requests { - slot.answered.get_or_insert(Ok(())); - continue; + self.with_state(|state, link| { + let link = attached(&mut state.links, link); + link.supported = false; + link.opening.clear(); + let requests = link.requests; + let mut answers = Vec::new(); + for (id, slot) in &mut link.slots { + if slot.withdrawn { + continue; + } + let answer = match requests { + true => Ok(()), + false => Err(Error::Unsupported), + }; + slot.answered.get_or_insert(answer.clone()); + if let Err(err) = &answer { + slot.ended.get_or_insert(err.clone()); + } + if !slot.setup { + answers.push((*id, answer)); + } } - slot.answered.get_or_insert(Err(Error::Unsupported)); - slot.ended.get_or_insert(Error::Unsupported); - } - // Nothing will read a withdrawn slot again. - state.tokens.retain(|_, slot| !slot.withdrawn); + // Nothing will read a withdrawn slot again. + link.slots.retain(|_, slot| !slot.withdrawn); + for (id, answer) in answers { + state.answer(id, answer.map(|()| Answer::Requests)); + } + }); } - /// End the session: fail every pending token, end every watch, and close the - /// requests. + /// Why the session ended, once it has. + fn closed(&self) -> Option { + self.read(|link, _| link.closed.clone()) + } + + /// End the session: fail every pending token here, leaving the handle's tokens for the + /// next session. A handle the session created for itself ends with it. pub(crate) fn close(&self, err: Error) { - let mut state = self.state.lock(); - if state.closed.is_some() { - return; - } - state.closed = Some(err.clone()); - state.opening.clear(); - for slot in state.tokens.values_mut() { - slot.grant = None; - slot.answered.get_or_insert(Err(err.clone())); - slot.ended.get_or_insert(err.clone()); - } - state.recompute(); - let queue = match &state.acceptor { - Acceptor::App(queue) => Some(queue.clone()), - _ => None, - }; - // Refusing the queued tokens takes this lock again, to drop them from the scope. - drop(state); + let queue = self.with_state(|state, link| { + let link = attached(&mut state.links, link); + if link.closed.is_some() { + return None; + } + link.closed = Some(err.clone()); + link.opening.clear(); + for slot in link.slots.values_mut() { + slot.grant = None; + slot.answered.get_or_insert(Err(err.clone())); + slot.ended.get_or_insert(err.clone()); + } + link.recompute(); + match state.bound { + true => state.abort(err), + false => None, + } + }); + // Refusing the queued tokens takes the lock again, to drop them from the scope. if let Some(queue) = queue { queue.close(); } } } +/// A session's side of the handle, which lives as long as any [`Link`] to it. +fn attached(links: &mut BTreeMap, id: u64) -> &mut Attached { + links.get_mut(&id).expect("a link outlives its session's state") +} + /// One credential this side presented, returned by [`Handle`]'s `add`. /// -/// Cheap to clone, and every clone names the same credential. It stays presented -/// until the last clone drops, which closes its stream and removes its grant from -/// the union. A [`Route`](crate::origin::Route) holding one keeps it presented while -/// the route stands. +/// Cheap to clone, and every clone names the same credential. It stays presented on +/// every session of its handle until the last clone drops, which closes its streams and +/// removes its grant from the union. A [`Route`](crate::origin::Route) holding one keeps +/// it presented while the route stands, across the handle's sessions. #[derive(Clone)] pub struct Auth { presented: std::sync::Arc, } -#[cfg(all(test, feature = "async"))] +#[cfg(test)] impl Auth { /// The id the session presents this credential under. pub(crate) fn id(&self) -> u64 { @@ -759,44 +1282,63 @@ impl Auth { } } -/// The slot every clone of one [`Auth`] shares, withdrawn when the last drops. +/// The token every clone of one [`Auth`] shares, withdrawn when the last drops. struct Presented { state: kio::Shared, id: u64, + /// The one session that presents it: a session's own credential. `None` for a + /// handle's token, presented on every session. + link: Option, +} + +impl Presented { + fn selector(&self) -> Selector { + match self.link { + Some(link) => Selector::Setup { link, id: self.id }, + None => Selector::Token(self.id), + } + } } impl Auth { - /// This credential's own grant: `None` until the peer answers, forever on - /// moq-transport without AUTH, and again once the credential ends. + /// This credential's own grant on the live session: `None` until its peer answers, + /// while no session is live, forever on moq-transport without AUTH, and again once + /// the credential ends. pub fn grant(&self) -> Watch { - Watch::new(self.presented.state.clone(), Selector::Slot(self.presented.id)) + Watch::new(self.presented.state.clone(), self.presented.selector()) } - /// Wait for the credential to end: the peer's revocation as [`Error::Session`], - /// [`Error::Cancel`] when the peer closed it without one, or the session's - /// close. + /// Wait for the credential to end for good: a peer's refusal or revocation as + /// [`Error::Session`], [`Error::Cancel`] when the peer closed it without one, or the + /// handle's end. A session ending is not one: the next session presents it again. #[cfg(feature = "async")] pub async fn closed(&self) -> Error { - let Presented { state, id } = &*self.presented; + let selector = self.presented.selector(); kio::wait(|waiter| { - let state = ready_or!(state.poll(waiter, |state| match state.tokens.get(id) { - Some(slot) if slot.ended.is_none() => Poll::Pending, - _ => Poll::Ready(()), + let state = ready_or!(self.presented.state.poll(waiter, |state| { + match selector.ended(state) { + Some(_) => Poll::Ready(()), + None => Poll::Pending, + } })); - let err = state.tokens.get(id).and_then(|slot| slot.ended.clone()); - Poll::Ready(err.unwrap_or(Error::Cancel)) + Poll::Ready(selector.ended(&state).unwrap_or(Error::Cancel)) }) .await } - fn poll_answered(&self, waiter: &kio::Waiter) -> Poll> { - let Presented { state, id } = &*self.presented; - let state = ready_or!(state.poll(waiter, |state| match state.tokens.get(id) { - Some(slot) if slot.answered.is_none() => Poll::Pending, - _ => Poll::Ready(()), + /// 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> { + let id = self.presented.id; + let answered = |state: &State| match state.tokens.get(&id) { + Some(token) => token.answered.clone(), + None => Some(Err(Error::Cancel)), + }; + let state = ready_or!(self.presented.state.poll(waiter, |state| match answered(state) { + Some(_) => Poll::Ready(()), + None => Poll::Pending, })); - let answered = state.tokens.get(id).and_then(|slot| slot.answered.clone()); - Poll::Ready(answered.unwrap_or(Err(Error::Cancel))) + Poll::Ready(answered(&state).unwrap_or(Err(Error::Cancel))) } } @@ -817,26 +1359,64 @@ impl std::fmt::Debug for Auth { impl Drop for Presented { fn drop(&mut self) { - let mut state = self.state.lock(); - let Some(slot) = state.tokens.get_mut(&self.id) else { - return; - }; - slot.withdrawn = true; - // Already over, or no stream whose close would end it: nothing left to close. - if slot.ended.is_some() || !slot.streamed { - state.opening.retain(|id| *id != self.id); - state.tokens.remove(&self.id); - state.recompute(); + let mut guard = self.state.lock(); + let state = &mut *guard; + match self.link { + Some(link) => { + if let Some(link) = state.links.get_mut(&link) { + link.withdraw(self.id); + } + } + None => { + state.tokens.remove(&self.id); + for link in state.links.values_mut() { + link.withdraw(self.id); + } + } } } } +/// Which grant a [`Watch`] follows. +#[derive(Clone, Copy)] enum Selector { + /// The live session's union. Union, - Slot(u64), + /// A handle's token, on the live session. + Token(u64), + /// A session's own credential, on that session. + Setup { link: u64, id: u64 }, +} + +impl Selector { + fn current<'a>(&self, state: &'a State) -> Option<&'a Grant> { + match *self { + Self::Union => state.live()?.union.as_ref(), + Self::Token(id) => state.live()?.slots.get(&id)?.grant.as_ref(), + Self::Setup { link, id } => state.links.get(&link)?.slots.get(&id)?.grant.as_ref(), + } + } + + /// Why nothing will change the grant again, once nothing will. + fn ended(&self, state: &State) -> Option { + if let Some(err) = &state.closed { + return Some(err.clone()); + } + match *self { + Self::Union => None, + Self::Token(id) => match state.tokens.get(&id) { + Some(token) => token.ended.clone(), + None => Some(Error::Cancel), + }, + Self::Setup { link, id } => match state.links.get(&link).and_then(|link| link.slots.get(&id)) { + Some(slot) => slot.ended.clone(), + None => Some(Error::Cancel), + }, + } + } } -/// Watches a grant: the session's union, or one credential's. +/// Watches a grant: the live session's union, or one credential's. /// /// Created by [`Handle::grant`] and [`Auth::grant`]. pub struct Watch { @@ -856,61 +1436,31 @@ impl Watch { watch } - fn current(&self, state: &State) -> Option { - match self.selector { - Selector::Union => state.union.clone(), - Selector::Slot(id) => state.tokens.get(&id).and_then(|slot| slot.grant.clone()), - } - } - - /// Whether nothing will change the grant again. - fn ended(&self, state: &State) -> Option { - if let Some(err) = state.closed() { - return Some(err); - } - match self.selector { - Selector::Union => None, - Selector::Slot(id) => match state.tokens.get(&id) { - Some(slot) => slot.ended.clone(), - None => Some(Error::Cancel), - }, - } - } - /// The grant right now. pub fn peek(&self) -> Option { - self.current(&self.state.read()) + self.selector.current(&self.state.read()).cloned() } /// Poll for the grant to differ from the last one this watch returned (or saw /// at creation). /// - /// `Err` once nothing will change it again: the session closed, or for a - /// token's watch, the token ended. + /// `Err` once nothing will change it again: the handle ended, or for a + /// token's watch, the token ended for good. pub fn poll_changed(&mut self, waiter: &kio::Waiter) -> Poll>> { let last = &self.last; - let selector = &self.selector; + let selector = self.selector; let state = ready_or!(self.state.poll(waiter, |state| { - let current = match selector { - Selector::Union => state.union.as_ref(), - Selector::Slot(id) => state.tokens.get(id).and_then(|slot| slot.grant.as_ref()), - }; - let ended = state.closed.is_some() - || match selector { - Selector::Union => false, - Selector::Slot(id) => state.tokens.get(id).is_none_or(|slot| slot.ended.is_some()), - }; - match current != last.as_ref() || ended { + match selector.current(state) != last.as_ref() || selector.ended(state).is_some() { true => Poll::Ready(()), false => Poll::Pending, } })); - let current = self.current(&state); + let current = selector.current(&state).cloned(); if current != self.last { self.last = current.clone(); return Poll::Ready(Ok(current)); } - Poll::Ready(Err(self.ended(&state).unwrap_or(Error::Cancel))) + Poll::Ready(Err(selector.ended(&state).unwrap_or(Error::Cancel))) } /// Wait for the grant to change, returning the new one. @@ -932,12 +1482,12 @@ pub struct Requests { } impl Requests { - /// Poll for the next token, or `None` once the session ends. + /// Poll for the next token, or `None` once the handle ends. pub fn poll_next(&mut self, waiter: &kio::Waiter) -> Poll> { self.queue.poll_next(waiter) } - /// Wait for the next token the peer presents, or `None` once the session ends. + /// Wait for the next token the peer presents, or `None` once the handle ends. #[cfg(feature = "async")] pub async fn next(&mut self) -> Option { kio::wait(|waiter| self.poll_next(waiter)).await @@ -1035,7 +1585,7 @@ pub(crate) struct Issue { taken: bool, /// An AUTH token's place in the session's scope, which its grant joins. A token that rode /// on a request has none: its grant covers only that request. - scoped: Option<(Handle, u64)>, + scoped: Option<(Link, u64)>, } impl Issue { @@ -1071,14 +1621,14 @@ pub(crate) type Hold = std::sync::Arc; /// already recorded why the stream ended, so no exit path leaves the acceptor waiting. pub(crate) struct Serving { pub issue: kio::Shared, - handle: Handle, + handle: Link, } impl Serving { /// A fresh stream's shared state, settled by the session `handle` if the task drops. - pub(crate) fn new(handle: Handle) -> Self { + pub(crate) fn new(handle: Link) -> Self { let id = { - let mut state = handle.state.lock(); + let mut state = handle.state().lock(); state.next_issue += 1; state.next_issue }; @@ -1104,7 +1654,7 @@ impl Serving { impl Drop for Serving { fn drop(&mut self) { - let err = self.handle.state.read().closed().unwrap_or(Error::Cancel); + let err = self.handle.closed().unwrap_or(Error::Cancel); self.end(err); } } @@ -1209,7 +1759,7 @@ impl RequestKind { } } -/// The verdict on a request-borne token, from [`Handle::verify_request`]. +/// The verdict on a request-borne token, from [`Link::verify_request`]. /// /// Holding it keeps the request's grant alive; dropping it tells the acceptor the request /// is over, so the grant lives exactly as long as the request and never outlives it. @@ -1387,7 +1937,7 @@ impl RequestGrant { } /// Start verifying a renewal's token, holding the request's slot while it runs. - pub(crate) fn verify_renewal(&self, handle: &Handle, token: crate::setup::Token) -> RequestVerdict { + pub(crate) fn verify_renewal(&self, handle: &Link, token: crate::setup::Token) -> RequestVerdict { let mut held = self.held(); let verdict = handle.verify_request(token, held.hold.clone()); held.renewing = verdict.issue.clone(); @@ -1574,7 +2124,7 @@ pub(crate) enum Direction { /// landing in between is never missed. Cheap to poll on every wakeup: the path is /// only re-matched when the union or the limit changes. pub(crate) struct Gate { - handle: Handle, + handle: Link, path: crate::PathOwned, direction: Direction, epoch: u64, @@ -1608,7 +2158,7 @@ enum Part { } impl Gate { - pub(crate) fn new(handle: Handle, path: crate::PathOwned, direction: Direction) -> Self { + pub(crate) fn new(handle: Link, path: crate::PathOwned, direction: Direction) -> Self { let within_limit = handle.within_limit(direction, path.as_str()); Self { handle, @@ -1631,7 +2181,7 @@ impl Gate { /// A gate for a request under a namespace the peer announced, while `live` stays open: /// the announce stands in for the scope, as its own token may have authorized it. pub(crate) fn announced( - handle: Handle, + handle: Link, path: crate::PathOwned, direction: Direction, live: kio::Consumer<()>, @@ -1737,7 +2287,7 @@ impl Gate { /// The default acceptor's answer to the peer's connection credential: the grant /// the session's origin handles allow, within the limit as it changes. pub(crate) struct DefaultGrant { - handle: Handle, + handle: Link, base: Grant, epoch: u64, limit: Option, @@ -1746,8 +2296,8 @@ pub(crate) struct DefaultGrant { } impl DefaultGrant { - pub(crate) fn new(handle: Handle, base: Grant) -> Self { - let limit = handle.state.read().limit.clone(); + pub(crate) fn new(handle: Link, base: Grant) -> Self { + let limit = handle.state().read().limit.clone(); Self { handle, base, @@ -1806,7 +2356,7 @@ impl Enforce { /// origin ends. pub(crate) fn poll( &mut self, - handle: &Handle, + handle: &Link, announced: &mut crate::announce::Consumer, waiter: &kio::Waiter, ) -> Poll> { @@ -1874,7 +2424,7 @@ mod tests { #[moq_clock::test] async fn a_request_waits_for_an_unanswered_token() { - let handle = Handle::new(true); + let handle = Link::new(true); let setup = handle.present(Bytes::new(), true).unwrap(); handle.granted(setup.presented.id, Grant::default()); let token = handle.present(Bytes::from_static(b"new"), false).unwrap(); @@ -1893,11 +2443,34 @@ mod tests { assert!(gate.poll_denied(&waiter).is_pending()); } + /// A token waiting out a transient refusal is unanswered, so a publication it might + /// cover waits for the retry instead of being judged unauthorized. + #[test] + fn a_retrying_token_holds_a_publication() { + let handle = Link::new(true); + let _token = handle.present(Bytes::from_static(b"jwt"), false).unwrap(); + let id = opened(&handle); + handle.refused(id); + let busy = Error::Session(SessionError::TooManyRequests); + assert_eq!(handle.retry(id, &busy), Some(RETRY_INITIAL)); + + let mut gate = Gate::new(handle.clone(), crate::Path::new("room").to_owned(), Direction::Publish); + let waiter = kio::Waiter::noop(); + assert!( + gate.poll_ready(&waiter).is_pending(), + "judged before the retry answered" + ); + handle.reopen(id).unwrap(); + handle.granted(id, Grant::all()); + assert!(gate.poll_ready(&waiter).is_ready()); + assert!(handle.within_grant(Direction::Publish, "room")); + } + /// A route withdrawn while its request waits on a token ends the wait, and the request /// is refused against the union as it stands. #[test] fn a_withdrawn_route_ends_the_wait() { - let handle = Handle::new(true); + let handle = Link::new(true); let setup = handle.present(Bytes::new(), true).unwrap(); handle.granted(setup.presented.id, Grant::default()); let _unanswered = handle.present(Bytes::from_static(b"new"), false).unwrap(); @@ -1917,7 +2490,7 @@ mod tests { #[test] fn an_empty_token_never_holds_a_request() { - let handle = Handle::new(true); + let handle = Link::new(true); let _setup = handle.present(Bytes::new(), true).unwrap(); let mut gate = Gate::new(handle, crate::Path::new("room").to_owned(), Direction::Subscribe); assert!(gate.poll_ready(&kio::Waiter::noop()).is_ready()); @@ -1925,7 +2498,7 @@ mod tests { #[test] fn a_covered_request_never_waits_for_an_unanswered_token() { - let handle = Handle::new(true); + let handle = Link::new(true); let first = handle.present(Bytes::from_static(b"first"), false).unwrap(); handle.granted(first.presented.id, Grant::all()); let _unanswered = handle.present(Bytes::from_static(b"renewal"), false).unwrap(); @@ -1935,8 +2508,8 @@ mod tests { #[test] fn an_auth_ok_is_queued_after_its_scope_widens() { - let handle = Handle::new(true); - handle.authorize(&Grant::default()); + let handle = Link::new(true); + handle.handle().authorize(&Grant::default()); let serving = Serving::new(handle.clone()); let request = Request::new(Bytes::from_static(b"token"), serving.issue.clone()); let _issued = request.accept(Grant::all()); @@ -1949,8 +2522,8 @@ mod tests { /// how many wait; answering it gives the slot back. #[test] fn a_queued_request_token_holds_its_slot() { - let handle = Handle::new(true); - let mut requests = handle.requests(); + let handle = Link::new(true); + let mut requests = handle.handle().requests(); let slot: Hold = std::sync::Arc::new(()); let token = crate::setup::Token { kind: crate::setup::Token::OUT_OF_BAND, @@ -1978,8 +2551,8 @@ mod tests { /// withdrawing cannot grow it past what its credit holds open. #[test] fn withdrawn_tokens_leave_the_queue() { - let handle = Handle::new(true); - let mut requests = handle.requests(); + let handle = Link::new(true); + let mut requests = handle.handle().requests(); let queue = handle.acceptor().unwrap(); queue.push(Request::new(Bytes::from_static(b"live"), Default::default())); let withdrawn = kio::Shared::::default(); @@ -2014,22 +2587,22 @@ mod tests { /// without AUTH too. #[test] fn a_limit_replaces_the_last() { - let handle = Handle::new(false); + let handle = Link::new(false); assert!(handle.allows(Direction::Publish, "room/alice/audio")); - handle.authorize(&grant(&["room/bob"], &["room/alice"])); + handle.handle().authorize(&grant(&["room/bob"], &["room/alice"])); // What we send is what the peer may subscribe to, and the other way around. assert!(handle.allows(Direction::Publish, "room/alice/audio")); assert!(!handle.allows(Direction::Publish, "room/bob/cam")); assert!(handle.allows(Direction::Subscribe, "room/bob/cam")); assert!(!handle.allows(Direction::Subscribe, "room/alice/audio")); - handle.authorize(&grant(&[""], &["room/alice/video"])); + handle.handle().authorize(&grant(&[""], &["room/alice/video"])); assert!(!handle.allows(Direction::Publish, "room/alice/audio")); assert!(handle.allows(Direction::Publish, "room/alice/video")); assert!(handle.allows(Direction::Subscribe, "other")); - handle.authorize(&Grant::all()); + handle.handle().authorize(&Grant::all()); assert!(handle.allows(Direction::Publish, "room/alice/audio")); assert!(handle.allows(Direction::Subscribe, "other")); } @@ -2037,13 +2610,13 @@ mod tests { /// Our own grant does not decide what the peer may offer us, only the limit does. #[test] fn the_limit_alone_decides_what_the_peer_offers() { - let handle = Handle::new(true); + let handle = Link::new(true); let token = handle.present(Bytes::new(), true).unwrap(); handle.granted(0, grant(&[], &[])); assert!(!handle.allows(Direction::Subscribe, "room/bob/cam")); assert!(handle.within_limit(Direction::Subscribe, "room/bob/cam")); - handle.authorize(&grant(&["room/alice"], &[])); + handle.handle().authorize(&grant(&["room/alice"], &[])); assert!(!handle.within_limit(Direction::Subscribe, "room/bob/cam")); assert!(handle.within_limit(Direction::Subscribe, "room/alice/cam")); drop(token); @@ -2053,22 +2626,22 @@ mod tests { /// limit changes, narrower or wider, and stays quiet on a change that leaves it alone. #[test] fn the_default_grant_follows_the_limit() { - let handle = Handle::new(true); + let handle = Link::new(true); let mut default = DefaultGrant::new(handle.clone(), grant(&["room"], &["room"])); let waiter = kio::Waiter::noop(); assert_eq!(default.poll(&waiter), Poll::Ready(grant(&["room"], &["room"]))); assert!(default.poll(&waiter).is_pending()); - handle.authorize(&grant(&["room"], &["room/alice"])); + handle.handle().authorize(&grant(&["room"], &["room/alice"])); assert_eq!(default.poll(&waiter), Poll::Ready(grant(&["room"], &["room/alice"]))); // Wider again, but never past what the origin handles allow. - handle.authorize(&Grant::all()); + handle.handle().authorize(&Grant::all()); assert_eq!(default.poll(&waiter), Poll::Ready(grant(&["room"], &["room"]))); // A limit that leaves the grant as it is: nothing to tell the peer. - handle.authorize(&grant(&[""], &[""])); + handle.handle().authorize(&grant(&[""], &[""])); assert!(default.poll(&waiter).is_pending()); } @@ -2077,17 +2650,24 @@ mod tests { /// could carry it. #[test] fn a_request_carries_an_auth_without_the_auth_stream() { - let ietf = Handle::ietf(false); + let ietf = Link::ietf(false); let auth = ietf.present(Bytes::from_static(b"jwt"), false).unwrap(); - assert!(matches!(auth.poll_answered(&kio::Waiter::noop()), Poll::Ready(Ok(())))); + assert!(matches!( + auth.poll_answered(&kio::Waiter::noop()), + Poll::Ready(Ok(Answer::Requests)) + )); assert_eq!(auth.grant().peek(), None); assert_eq!(ietf.token(&auth).as_deref(), Some(b"jwt".as_slice())); - assert!(ietf.state.read().opening.is_empty(), "nothing to open a stream for"); + assert!( + ietf.read(|link, _| link.opening.is_empty()), + "nothing to open a stream for" + ); - let lite = Handle::new(false); + let lite = Link::new(false); + let auth = lite.present(Bytes::from_static(b"jwt"), false).unwrap(); assert!(matches!( - lite.present(Bytes::from_static(b"jwt"), false), - Err(Error::Unsupported) + auth.poll_answered(&kio::Waiter::noop()), + Poll::Ready(Err(Error::Unsupported)) )); } @@ -2095,11 +2675,14 @@ mod tests { /// requests rather than failing it. #[test] fn an_unsupported_peer_keeps_a_request_auth() { - let handle = Handle::ietf(true); + let handle = Link::ietf(true); let auth = handle.present(Bytes::from_static(b"jwt"), false).unwrap(); assert!(auth.poll_answered(&kio::Waiter::noop()).is_pending()); handle.unsupported(); - assert!(matches!(auth.poll_answered(&kio::Waiter::noop()), Poll::Ready(Ok(())))); + assert!(matches!( + auth.poll_answered(&kio::Waiter::noop()), + Poll::Ready(Ok(Answer::Requests)) + )); assert_eq!(handle.token(&auth).as_deref(), Some(b"jwt".as_slice())); } @@ -2107,15 +2690,15 @@ mod tests { /// auth goes out under this session's scope. #[test] fn another_handle_does_not_carry_an_auth() { - let issuer = Handle::ietf(false); + let issuer = Link::ietf(false); let auth = issuer.present(Bytes::from_static(b"jwt"), false).unwrap(); - assert_eq!(Handle::ietf(false).token(&auth), None); + assert_eq!(Link::ietf(false).token(&auth), None); } /// Every clone names one credential, which stays presented until the last drops. #[test] fn the_last_clone_withdraws_the_auth() { - let handle = Handle::new(true); + let handle = Link::new(true); let auth = handle.present(Bytes::from_static(b"jwt"), false).unwrap(); let Poll::Ready(Some((id, _))) = handle.poll_opening(&kio::Waiter::noop()) else { panic!("nothing to open"); @@ -2136,6 +2719,217 @@ mod tests { drop(clone); assert!(handle.poll_withdrawn(id, &kio::Waiter::noop()).is_ready()); } + + fn opened(link: &Link) -> u64 { + match link.poll_opening(&kio::Waiter::noop()) { + Poll::Ready(Some((id, _))) => id, + _ => panic!("nothing to open"), + } + } + + /// Only the peer's verdict ends a handle's token for good: a session ending leaves it for + /// the next session, which presents it again, and one active session at a time. + #[test] + fn only_a_verdict_ends_a_shared_token() { + 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 id = opened(&first); + first.granted(id, Grant::all()); + assert!(matches!( + auth.poll_answered(&kio::Waiter::noop()), + Poll::Ready(Ok(Answer::Granted)) + )); + assert_eq!(auth.grant().peek(), Some(Grant::all())); + + first.close(Error::Cancel); + assert_eq!(auth.grant().peek(), None, "no session is live"); + assert!(Selector::Token(id).ended(&handle.state.read()).is_none()); + + let second = Link::attach(&handle, true, false).unwrap(); + assert_eq!(opened(&second), id, "the next session presents it again"); + second.refused(id); + let refused = Error::Session(SessionError::Unauthorized); + assert_eq!(second.retry(id, &refused), None); + second.ended(id, refused); + assert!(matches!( + Selector::Token(id).ended(&handle.state.read()), + Some(Error::Session(SessionError::Unauthorized)) + )); + + second.close(Error::Cancel); + let third = Link::attach(&handle, true, false).unwrap(); + assert!( + third.poll_opening(&kio::Waiter::noop()).is_pending(), + "a refused token stays ended" + ); + } + + /// The answer records how the peer accepted a token, so a grant whose session drops before + /// the caller reads it is still told apart from a token only requests carry. + #[test] + 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(); + session.granted(opened(&session), Grant::all()); + session.close(Error::Cancel); + assert_eq!(auth.grant().peek(), None); + assert!(matches!( + auth.poll_answered(&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(); + assert!(matches!( + auth.poll_answered(&kio::Waiter::noop()), + Poll::Ready(Ok(Answer::Requests)) + )); + } + + /// A session's own handle has no successor: a token added after its GOAWAY fails at once, + /// and one whose stream ends without a verdict ends for good. + #[test] + fn a_sessions_own_handle_ends_its_tokens() { + let link = Link::new(true); + let auth = link.present(Bytes::from_static(b"jwt"), false).unwrap(); + let id = opened(&link); + link.ended(id, Error::Stream(crate::StreamError::Internal)); + assert!(matches!( + auth.poll_answered(&kio::Waiter::noop()), + Poll::Ready(Err(Error::Stream(_))) + )); + + let (goaway, _protocol) = crate::goaway::Handle::new(false); + goaway.producer().send(crate::goaway::Goaway::new()).unwrap(); + link.drain_with(std::sync::Arc::new(goaway)); + assert!(matches!( + link.present(Bytes::from_static(b"late"), false), + Err(Error::GoingAway) + )); + } + + /// A token added while a session without AUTH drains rides only its successor, so the + /// draining session cannot end it as unsupported. + #[test] + fn a_draining_session_takes_no_new_token() { + let handle = Handle::new(); + let draining = Link::attach(&handle, false, false).unwrap(); + let (goaway, _protocol) = crate::goaway::Handle::new(false); + goaway.producer().send(crate::goaway::Goaway::new()).unwrap(); + 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(); + assert!(Selector::Token(auth.id()).ended(&handle.state.read()).is_none()); + let id = opened(&successor); + successor.granted(id, Grant::all()); + assert!(matches!( + auth.poll_answered(&kio::Waiter::noop()), + Poll::Ready(Ok(Answer::Granted)) + )); + } + + /// A draining session's late refusal ends the token there alone while its successor + /// presents it, so a stale verdict never withdraws a live grant. + #[test] + 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 id = opened(&draining); + let (goaway, _protocol) = crate::goaway::Handle::new(false); + goaway.producer().send(crate::goaway::Goaway::new()).unwrap(); + draining.drain_with(std::sync::Arc::new(goaway)); + + let successor = Link::attach(&handle, true, false).unwrap(); + assert_eq!(opened(&successor), id); + successor.granted(id, Grant::all()); + draining.refused(id); + draining.ended(id, Error::Session(SessionError::Unauthorized)); + assert!(Selector::Token(id).ended(&handle.state.read()).is_none()); + assert_eq!(auth.grant().peek(), Some(Grant::all())); + } + + /// A draining session's late refusal leaves a token its successor is still deciding, + /// including one the successor granted and then transiently refused. + #[test] + 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 id = opened(&draining); + let (goaway, _protocol) = crate::goaway::Handle::new(false); + goaway.producer().send(crate::goaway::Goaway::new()).unwrap(); + draining.drain_with(std::sync::Arc::new(goaway)); + + let successor = Link::attach(&handle, true, false).unwrap(); + assert_eq!(opened(&successor), id); + successor.granted(id, Grant::all()); + successor.refused(id); + let busy = Error::Session(SessionError::TooManyRequests); + assert_eq!(successor.retry(id, &busy), Some(RETRY_INITIAL)); + + draining.refused(id); + draining.ended(id, Error::Session(SessionError::Unauthorized)); + assert!(Selector::Token(id).ended(&handle.state.read()).is_none()); + + assert_eq!(successor.reopen(id).unwrap().as_ref(), b"jwt"); + successor.granted(id, Grant::all()); + assert_eq!(auth.grant().peek(), Some(Grant::all())); + } + + /// A retry that wakes after this side sent a GOAWAY opens nothing: a successor presents + /// it, and on a session's own handle the token ends as going away, not as a clean close. + #[test] + fn a_draining_session_presents_no_retry() { + let link = Link::new(true); + let (goaway, _protocol) = crate::goaway::Handle::new(false); + let goaway = std::sync::Arc::new(goaway); + link.drain_with(goaway.clone()); + let auth = link.present(Bytes::from_static(b"jwt"), false).unwrap(); + let id = opened(&link); + link.refused(id); + let busy = Error::Session(SessionError::TooManyRequests); + assert_eq!(link.retry(id, &busy), Some(RETRY_INITIAL)); + + goaway.producer().send(crate::goaway::Goaway::new()).unwrap(); + assert!(!link.active()); + let err = link.reopen(id).unwrap_err(); + assert!(matches!(err, Error::GoingAway), "{err:?}"); + link.ended(id, err); + assert!(matches!( + Selector::Token(auth.id()).ended(&link.state().read()), + Some(Error::GoingAway) + )); + } + + /// A refusal that may pass is presented again after a doubling backoff, and settles nothing. + #[test] + fn a_transient_refusal_backs_off() { + let link = Link::new(true); + let auth = link.present(Bytes::from_static(b"jwt"), false).unwrap(); + let id = opened(&link); + let busy = Error::Session(SessionError::TooManyRequests); + + link.refused(id); + assert_eq!(link.retry(id, &busy), Some(RETRY_INITIAL)); + assert!(auth.poll_answered(&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)); + assert_eq!(link.retry(id, &busy), None, "only a refusal retries"); + + link.reopen(id).unwrap(); + link.granted(id, Grant::all()); + assert!(matches!( + auth.poll_answered(&kio::Waiter::noop()), + Poll::Ready(Ok(Answer::Granted)) + )); + } } #[cfg(all(test, feature = "async"))] @@ -2157,8 +2951,8 @@ mod request_token_tests { /// app answers is what the verdict resolves to. #[moq_clock::test] async fn a_request_token_reaches_the_acceptor() { - let handle = Handle::new(true); - let mut requests = handle.requests(); + let handle = Link::new(true); + let mut requests = handle.handle().requests(); let verdict = handle.verify_request(token(b"jwt", 0), None); let request = requests.next().await.expect("a request"); @@ -2173,8 +2967,8 @@ mod request_token_tests { /// the acceptor as one: it is unsupported, never handed over as if it were a plain token. #[moq_clock::test] async fn a_typed_request_token_is_unsupported() { - let handle = Handle::new(true); - let mut requests = handle.requests(); + let handle = Link::new(true); + let mut requests = handle.handle().requests(); let verdict = handle.verify_request(token(b"cat", crate::setup::Token::CAT), None); let err = verdict.grant().await.expect_err("a typed token"); assert!(matches!(err.error, Error::Unsupported), "{err:?}"); @@ -2188,8 +2982,8 @@ mod request_token_tests { /// connection's own credential. #[test] fn an_empty_request_token_is_unsupported() { - let handle = Handle::new(true); - let mut requests = handle.requests(); + let handle = Link::new(true); + let mut requests = handle.handle().requests(); let verdict = handle.verify_request(token(b"", crate::setup::Token::OUT_OF_BAND), None); assert!(matches!( verdict.poll_grant(&kio::Waiter::noop()), @@ -2210,8 +3004,8 @@ mod request_token_tests { /// a standard moq-transport peer presents when it does not negotiate the AUTH extension. #[moq_clock::test] async fn a_request_token_is_verified_without_the_auth_extension() { - let handle = Handle::new(false); - let mut requests = handle.requests(); + let handle = Link::new(false); + let mut requests = handle.handle().requests(); handle.unsupported(); let verdict = handle.verify_request(token(b"jwt", 0), None); let request = requests.next().await.expect("a request reaches the acceptor"); @@ -2226,7 +3020,7 @@ mod request_token_tests { /// is Unsupported and the caller refuses the request NOT_SUPPORTED. #[moq_clock::test] async fn no_consumer_refuses_a_request_token_as_unsupported() { - let handle = Handle::new(true); + let handle = Link::new(true); let verdict = handle.verify_request(token(b"jwt", 0), None); let err = verdict.grant().await.expect_err("no verifier"); assert!(matches!(err.error, Error::Unsupported), "{err:?}"); @@ -2236,8 +3030,8 @@ mod request_token_tests { /// UNAUTHORIZED. #[moq_clock::test] async fn a_refused_request_token_is_unauthorized() { - let handle = Handle::new(true); - let mut requests = handle.requests(); + let handle = Link::new(true); + let mut requests = handle.handle().requests(); let verdict = handle.verify_request(token(b"jwt", 0), None); requests.next().await.unwrap().reject(SessionError::Unauthorized, "no"); let err = verdict.grant().await.expect_err("refused"); @@ -2251,8 +3045,8 @@ mod request_token_tests { /// as it stands now, never as first issued. #[moq_clock::test] async fn a_grant_ended_before_admission_is_refused() { - let handle = Handle::new(true); - let mut requests = handle.requests(); + let handle = Link::new(true); + let mut requests = handle.handle().requests(); let verdict = handle.verify_request(token(b"jwt", 0), None); let issued = requests.next().await.unwrap().accept(Grant::all()); @@ -2282,8 +3076,8 @@ mod request_token_tests { /// acceptor's issued grant, so it never outlives the request nor joins the union. #[moq_clock::test] async fn dropping_the_verdict_ends_the_grant() { - let handle = Handle::new(true); - let mut requests = handle.requests(); + let handle = Link::new(true); + let mut requests = handle.handle().requests(); let verdict = handle.verify_request(token(b"jwt", 0), None); let issued = requests.next().await.unwrap().accept(Grant::all()); verdict.grant().await.expect("granted"); @@ -2296,14 +3090,18 @@ mod request_token_tests { /// credential earned, not what a request token granted. #[moq_clock::test] async fn a_request_token_never_joins_the_union() { - let handle = Handle::new(true); - let mut requests = handle.requests(); + let handle = Link::new(true); + let mut requests = handle.handle().requests(); let verdict = handle.verify_request(token(b"jwt", 0), None); let _issued = requests.next().await.unwrap().accept(Grant::all()); verdict.grant().await.expect("granted"); // The union only reflects tokens presented on this side (none here), never a // request token the acceptor answered. - assert_eq!(handle.grant().peek(), None, "a request token must not widen the union"); + assert_eq!( + handle.handle().grant().peek(), + None, + "a request token must not widen the union" + ); } fn subscribe_only(prefix: &str) -> Grant { @@ -2319,9 +3117,9 @@ mod request_token_tests { /// request never widens what tokenless requests may do. #[moq_clock::test] async fn the_scope_is_session_tokens_only() { - let handle = Handle::new(true); - let mut requests = handle.requests(); - handle.authorize(&subscribe_only("room/alice")); + let handle = Link::new(true); + let mut requests = handle.handle().requests(); + handle.handle().authorize(&subscribe_only("room/alice")); assert!(!handle.allows(Direction::Publish, "room/bob")); // A token on a request admits that request, and nothing else. @@ -2365,9 +3163,9 @@ mod request_token_tests { /// Revoking or dropping an AUTH token's grant takes it out of the scope. #[moq_clock::test] async fn an_ended_auth_grant_leaves_the_scope() { - let handle = Handle::new(true); - let mut requests = handle.requests(); - handle.authorize(&Grant::default()); + let handle = Link::new(true); + let mut requests = handle.handle().requests(); + handle.handle().authorize(&Grant::default()); for revoke in [true, false] { let serving = Serving::new(handle.clone()); requests @@ -2387,8 +3185,8 @@ mod request_token_tests { /// tokens were granted: the scope only exists once the connection credential has a grant. #[moq_clock::test] async fn an_unauthorized_session_has_no_scope() { - let handle = Handle::new(true); - let mut requests = handle.requests(); + let handle = Link::new(true); + let mut requests = handle.handle().requests(); let serving = Serving::new(handle.clone()); requests .queue @@ -2411,8 +3209,8 @@ mod request_token_tests { #[moq_clock::test] async fn a_request_grant_has_the_acceptors_expiry() { let runtime = crate::time::sim(); - let handle = Handle::new(true); - let mut requests = handle.requests(); + let handle = Link::new(true); + let mut requests = handle.handle().requests(); let verdict = handle.verify_request(token(b"jwt", 0), None); let grant = grant_in(&runtime, 60); let expires = grant.expires; @@ -2436,8 +3234,8 @@ mod request_token_tests { #[moq_clock::test] async fn an_already_expired_grant_is_refused() { let runtime = crate::time::sim(); - let handle = Handle::new(true); - let mut requests = handle.requests(); + let handle = Link::new(true); + let mut requests = handle.handle().requests(); let expired = || Grant { expires: Some(runtime.now()), ..grant_in(&runtime, 0) @@ -2484,8 +3282,8 @@ mod request_token_tests { #[moq_clock::test] async fn a_renewal_token_replaces_the_grant_and_expiry() { let runtime = crate::time::sim(); - let handle = Handle::new(true); - let mut requests = handle.requests(); + let handle = Link::new(true); + let mut requests = handle.handle().requests(); let verdict = handle.verify_request(token(b"jwt", 0), None); let first = grant_in(&runtime, 60); @@ -2522,8 +3320,8 @@ mod request_token_tests { #[moq_clock::test] async fn the_deadline_expires_the_request_without_renewal() { let runtime = crate::time::sim(); - let handle = Handle::new(true); - let mut requests = handle.requests(); + let handle = Link::new(true); + let mut requests = handle.handle().requests(); let verdict = handle.verify_request(token(b"jwt", 0), None); // Held for the request's life, so only the deadline (not a drop) ends it. let _issued = requests.next().await.unwrap().accept(grant_in(&runtime, 60)); @@ -2540,7 +3338,7 @@ mod request_token_tests { let err = kio::wait(|waiter| request_grant.poll_ended(waiter)).await.error; assert!(matches!(err, Error::Expired), "{err:?}"); - assert_eq!(handle.grant().peek(), None, "the session grant is untouched"); + assert_eq!(handle.handle().grant().peek(), None, "the session grant is untouched"); } /// A renewal the acceptor granted before the old deadline lapsed keeps the request, even @@ -2548,8 +3346,8 @@ mod request_token_tests { #[moq_clock::test] async fn a_granted_renewal_outlives_the_old_deadline() { let runtime = crate::time::sim(); - let handle = Handle::new(true); - let mut requests = handle.requests(); + let handle = Link::new(true); + let mut requests = handle.handle().requests(); let verdict = handle.verify_request(token(b"jwt", 0), None); let _issued = requests.next().await.unwrap().accept(grant_in(&runtime, 60)); let answered = verdict.grant().await.unwrap(); @@ -2586,8 +3384,8 @@ mod request_token_tests { #[moq_clock::test] async fn a_refused_renewal_leaves_the_grant() { let runtime = crate::time::sim(); - let handle = Handle::new(true); - let mut requests = handle.requests(); + let handle = Link::new(true); + let mut requests = handle.handle().requests(); let verdict = handle.verify_request(token(b"jwt", 0), None); let first = grant_in(&runtime, 60); let first_expires = first.expires; @@ -2632,8 +3430,8 @@ mod request_token_tests { #[moq_clock::test] async fn an_update_that_stops_covering_ends_the_request() { let runtime = crate::time::sim(); - let handle = Handle::new(true); - let mut requests = handle.requests(); + let handle = Link::new(true); + let mut requests = handle.handle().requests(); let verdict = handle.verify_request(token(b"jwt", 0), None); let issued = requests.next().await.unwrap().accept(Grant::all()); let answered = verdict.grant().await.unwrap(); @@ -2715,8 +3513,8 @@ mod request_token_tests { #[moq_clock::test] async fn an_acceptor_revoke_ends_the_request() { let runtime = crate::time::sim(); - let handle = Handle::new(true); - let mut requests = handle.requests(); + let handle = Link::new(true); + let mut requests = handle.handle().requests(); let verdict = handle.verify_request(token(b"jwt", 0), None); // A grant that never expires, so only the revoke can end the request. let issued = requests.next().await.unwrap().accept(Grant::all()); @@ -2738,6 +3536,6 @@ mod request_token_tests { "{refusal:?}" ); assert_eq!(refusal.reason, "revoked", "the acceptor's reason is kept"); - assert_eq!(handle.grant().peek(), None, "the session grant is untouched"); + assert_eq!(handle.handle().grant().peek(), None, "the session grant is untouched"); } } diff --git a/rs/moq-net/src/client.rs b/rs/moq-net/src/client.rs index ec1096b9657..806b1358ef3 100644 --- a/rs/moq-net/src/client.rs +++ b/rs/moq-net/src/client.rs @@ -22,6 +22,7 @@ pub struct Client { peer_hop: Option, limits: crate::session::Limits, extensions: setup::Extensions, + auth: Option, bandwidth: Option, implementation: Option, } @@ -116,6 +117,18 @@ impl Client { self } + /// Build every session on `auth`, so each presents the tokens it holds and a route + /// naming one of its [`Auth`](crate::auth::Auth)s keeps it across a reconnect. Omit to + /// give each session a handle of its own, which ends with it. + /// + /// The handle backs one active session at a time, plus the ones draining after a + /// GOAWAY: connecting another while one is still active fails with + /// [`Error::Duplicate`]. + pub fn with_auth(mut self, auth: crate::auth::Handle) -> Self { + self.auth = Some(auth); + self + } + /// Choose which extensions to offer in SETUP. Defaults to every moq-transport one, and /// not [`ietf`](setup::Extensions::ietf), which only relays turn on. pub fn with_extensions(mut self, extensions: setup::Extensions) -> Self { @@ -185,6 +198,16 @@ impl Client { self.peer_hop.unwrap_or_else(crate::Hop::random) } + /// The new session's side of its auth handle: the one from [`with_auth`](Self::with_auth), + /// or one of its own. `requests` for moq-transport, where a request carries a token. + fn link(&self, supported: bool, requests: bool) -> Result { + match &self.auth { + Some(handle) => crate::auth::Link::attach(handle, supported, requests), + None if requests => Ok(crate::auth::Link::ietf(supported)), + None => Ok(crate::auth::Link::new(supported)), + } + } + /// The origin pair a session attaches, tagged and filtered. /// /// Reads through the publish (egress) consumer and writes through the @@ -252,7 +275,7 @@ impl Client { version, our_setup, peer_setup: None, - auth: crate::auth::Handle::new(version.has_auth()), + auth: self.link(version.has_auth(), false)?, })?; Ok(Session::new( @@ -308,7 +331,7 @@ impl Client { // We advertise the request path in our SETUP for URL-less transports. // The peer's SETUP decides whether AUTH is negotiated. A handle that does not // offer it keeps the option out of our SETUP and presents no credential. - let auth = crate::auth::Handle::ietf(self.extensions.auth); + let auth = self.link(self.extensions.auth, true)?; let peer_implementation = setup::PeerImplementation::default(); let (protocol, goaway) = ietf::start(ietf::Config { runtime: runtime.clone(), @@ -464,7 +487,7 @@ impl Client { our_setup: lite::Setup::default(), peer_setup: None, // Negotiated over the bidi SETUP: lite 01/02, which carry no AUTH. - auth: crate::auth::Handle::new(v.has_auth()), + auth: self.link(v.has_auth(), false)?, })?; ( @@ -497,7 +520,7 @@ impl Client { let stream = stream.with_version(v); // Draft 14-16 carry no AUTH, but the session is still limited through this handle. - let auth = crate::auth::Handle::ietf(false); + let auth = self.link(false, true)?; // Draft 14-16: the path rode in the bidi SETUP above, not the uni one. let (protocol, goaway) = ietf::start(ietf::Config { runtime: runtime.clone(), diff --git a/rs/moq-net/src/goaway.rs b/rs/moq-net/src/goaway.rs index d4fdde19844..fa08a21ed16 100644 --- a/rs/moq-net/src/goaway.rs +++ b/rs/moq-net/src/goaway.rs @@ -376,6 +376,11 @@ impl Handle { pub fn consumer(&self) -> Consumer { self.consumer.clone() } + + /// Whether either side sent a GOAWAY, so the session is draining. + pub fn draining(&self) -> bool { + self.trigger.read().is_some() || self.consumer.state.read().is_some() + } } #[cfg(test)] diff --git a/rs/moq-net/src/ietf/auth.rs b/rs/moq-net/src/ietf/auth.rs index 9016a1ba943..90dca42827e 100644 --- a/rs/moq-net/src/ietf/auth.rs +++ b/rs/moq-net/src/ietf/auth.rs @@ -12,7 +12,7 @@ use std::time::Duration; use bytes::Bytes; -use crate::auth::{Grant, Handle, Issue, Reply, Request, Serving}; +use crate::auth::{Grant, Issue, Link, Reply, Request, Serving}; use crate::coding::{Decode, DecodeError, Decoder, Encode, EncodeError, Encoder}; use crate::{Error, Patterns, SessionError}; @@ -225,18 +225,13 @@ pub(super) fn peer_grant( /// says it negotiated the extension. Never returns: a session without it just fails /// the tokens as unsupported. #[cfg(feature = "async")] -#[expect( - clippy::too_many_arguments, - reason = "the presenting task owns each piece of session state it drives" -)] pub(super) async fn run_present( runtime: crate::time::Deadlines, session: Transport, control: Control, - handle: Handle, + handle: Link, peer_setup: peer::PeerSetup, version: Version, - going_away: crate::goaway::GoingAway, stats: crate::stats::Session, ) -> Result<(), Error> { if !peer_setup.get().await.auth { @@ -267,13 +262,13 @@ pub(super) async fn run_present( version, id, }; - let going_away = going_away.clone(); let violation = violation.clone(); tasks.push(async move { - // After a GOAWAY the peer must not see new requests. - let err = match going_away.is_set() { - true => Error::GoingAway, - false => present.run(token).await, + // After a GOAWAY, sent or received, the peer must not see new requests. AUTH_ERROR + // carries no code that may pass, so unlike moq-lite a refusal is never presented again. + let err = match present.handle.active() { + true => present.run(token).await, + false => Error::GoingAway, }; let err = crate::auth::protocol_error(err); if matches!(err, Error::ProtocolViolation) { @@ -299,7 +294,7 @@ struct Present { runtime: crate::time::Deadlines, session: Transport, control: Control, - handle: Handle, + handle: Link, version: Version, id: u64, } @@ -410,7 +405,7 @@ fn decode_reply(body: &super::Body, version: Version) -> Result, // Subscriptions the peer holds, PUBLISHes for its listings included, counted for the peak. @@ -706,7 +706,7 @@ pub(super) struct Publisher { /// Authorization selected by dispatch: a standalone path or a live parent subscription. enum FetchGate { Standalone(crate::auth::Gate), - Joining { id: RequestId, auth: crate::auth::Handle }, + Joining { id: RequestId, auth: crate::auth::Link }, } struct Serve(Arc); @@ -775,7 +775,7 @@ impl Publisher { peer_setup, joins: Default::default(), version, - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), owed: Default::default(), subscriptions: Default::default(), priority: Default::default(), @@ -784,7 +784,7 @@ impl Publisher { } /// Bound what we publish by the grant this session's tokens earn (MoQ Auth). - pub fn with_auth(mut self, auth: crate::auth::Handle) -> Self { + pub fn with_auth(mut self, auth: crate::auth::Link) -> Self { self.auth = auth; self } @@ -8449,7 +8449,7 @@ mod serve_tests { )); assert!(futures::poll!(running.as_mut()).is_pending()); let mark = session.log.writes.lock().unwrap().len(); - h.publisher.auth.authorize(&crate::auth::Grant::default()); + h.publisher.auth.handle().authorize(&crate::auth::Grant::default()); // An otherwise valid no-op update is ready at the same time as revocation. session.push(&[0x02, 0, 2, 2, 0]); assert!(futures::poll!(running.as_mut()).is_pending()); @@ -9012,8 +9012,8 @@ mod serve_tests { ._origin .dynamic_route("live", crate::origin::Route::default()) .unwrap(); - let auth = crate::auth::Handle::new(false); - auth.authorize(¬hing()); + let auth = crate::auth::Link::new(false); + auth.handle().authorize(¬hing()); h.publisher = h.publisher.clone().with_auth(auth); let code = moq_clock::timeout( std::time::Duration::from_secs(1), @@ -9035,7 +9035,7 @@ mod serve_tests { ._origin .dynamic_route("live", crate::origin::Route::default()) .unwrap(); - let auth = crate::auth::Handle::new(false); + let auth = crate::auth::Link::new(false); h.publisher = h.publisher.clone().with_auth(auth.clone()); let stream = Stream::open(&mut crate::driver::Transport::new(h.session.clone()), version) @@ -9058,7 +9058,7 @@ mod serve_tests { // Parked resolving the broadcast when the grant narrows. let request = claim.requested_broadcast().await.unwrap(); - auth.authorize(¬hing()); + auth.handle().authorize(¬hing()); let output = crate::broadcast::Info::new().produce(); let _track = output.create_track("video", None).unwrap(); request.accept(&output); @@ -9080,8 +9080,8 @@ mod serve_tests { }; for version in [Version::Draft14, Version::Draft22] { let mut h = serve(version); - let auth = crate::auth::Handle::new(false); - auth.authorize(¬hing); + let auth = crate::auth::Link::new(false); + auth.handle().authorize(¬hing); h.publisher = h.publisher.clone().with_auth(auth); let mut body = track_status("room", true, version); @@ -9108,7 +9108,7 @@ mod serve_tests { let session = SinkSession::gated_bi(bi.consume()); let peer_setup = peer::PeerSetup::default(); peer_setup.set(peer::Peer::default()); - let auth = crate::auth::Handle::new(false); + let auth = crate::auth::Link::new(false); let publisher = Publisher::new( crate::time::sim(), crate::driver::Transport::new(session.clone()), @@ -9145,7 +9145,7 @@ mod serve_tests { moq_clock::sim::yield_now().await; } - auth.authorize(&crate::auth::Grant { + auth.handle().authorize(&crate::auth::Grant { publish: Default::default(), subscribe: Default::default(), expires: None, @@ -11309,8 +11309,8 @@ mod serve_tests { let mut h = serve(version); publish_pairs(&mut h, 1, None); settle().await; - let auth = crate::auth::Handle::new(false); - auth.authorize(¬hing); + let auth = crate::auth::Link::new(false); + auth.handle().authorize(¬hing); h.publisher = h.publisher.clone().with_auth(auth); let buf = standalone_fetch(&h, location, location, GroupOrder::Ascending).await; @@ -11326,7 +11326,7 @@ mod serve_tests { let h = serve(version); let _group = h.track.clone().create_group(group::Info { sequence: 0 }).unwrap(); settle().await; - let auth = crate::auth::Handle::new(false); + let auth = crate::auth::Link::new(false); let mut h = h; h.publisher = h.publisher.clone().with_auth(auth.clone()); @@ -11340,7 +11340,7 @@ mod serve_tests { futures::poll!(fetch.as_mut()).is_pending(), "{version}: served an unwritten group" ); - auth.authorize(¬hing); + auth.handle().authorize(¬hing); let buf = moq_clock::timeout(std::time::Duration::from_secs(1), fetch) .await .unwrap_or_else(|_| panic!("{version}: still loading after the grant narrowed")); @@ -11371,7 +11371,7 @@ mod serve_tests { let session = SinkSession::gated_bi(bi.consume()).with_open_uni_gate(credit.consume()); let peer_setup = peer::PeerSetup::default(); peer_setup.set(peer::Peer::default()); - let auth = crate::auth::Handle::new(false); + let auth = crate::auth::Link::new(false); let publisher = Publisher::new( crate::time::sim(), crate::driver::Transport::new(session.clone()), @@ -11422,7 +11422,7 @@ mod serve_tests { "{version}: FETCH_OK never went out" ); - auth.authorize(&crate::auth::Grant { + auth.handle().authorize(&crate::auth::Grant { publish: Default::default(), subscribe: Default::default(), expires: None, @@ -11466,7 +11466,7 @@ mod serve_tests { let session = SinkSession::gated_bi(bi.consume()).with_open_uni_gate(credit.consume()); let peer_setup = peer::PeerSetup::default(); peer_setup.set(peer::Peer::default()); - let auth = crate::auth::Handle::new(false); + let auth = crate::auth::Link::new(false); let publisher = Publisher::new( crate::time::sim(), crate::driver::Transport::new(session.clone()), @@ -11528,7 +11528,7 @@ mod serve_tests { "{version}: FETCH_OK never went out" ); - auth.authorize(&crate::auth::Grant { + auth.handle().authorize(&crate::auth::Grant { publish: Default::default(), subscribe: Default::default(), expires: None, @@ -11574,10 +11574,10 @@ mod serve_tests { let session = SinkSession::gated_bi(bi.consume()).with_open_uni_gate(credit.consume()); let peer_setup = peer::PeerSetup::default(); peer_setup.set(peer::Peer::default()); - let auth = crate::auth::Handle::new(false); - let mut requests = auth.requests(); + let auth = crate::auth::Link::new(false); + let mut requests = auth.handle().requests(); // A limit of nothing, so only the token admits the subscription. - auth.authorize(&match token { + auth.handle().authorize(&match token { true => crate::auth::Grant::default(), false => crate::auth::Grant::all(), }); @@ -11726,10 +11726,10 @@ mod serve_tests { let session = SinkSession::gated_bi(bi.consume()).with_open_uni_gate(credit.consume()); let peer_setup = peer::PeerSetup::default(); peer_setup.set(peer::Peer::default()); - let auth = crate::auth::Handle::new(false); - let mut requests = auth.requests(); + let auth = crate::auth::Link::new(false); + let mut requests = auth.handle().requests(); // A limit of nothing, so only the token admits the subscription. - auth.authorize(&crate::auth::Grant::default()); + auth.handle().authorize(&crate::auth::Grant::default()); let publisher = Publisher::new( crate::time::sim(), crate::driver::Transport::new(session.clone()), @@ -12184,13 +12184,13 @@ mod serve_tests { /// Like [`serve`], but with an app auth acceptor wired in and the subscribe stream's /// reader scripted with `first_script` (a REQUEST_UPDATE, for the renewal tests). - fn serve_with_auth(version: Version, auth: crate::auth::Handle, first_script: Vec) -> Serve { + fn serve_with_auth(version: Version, auth: crate::auth::Link, first_script: Vec) -> Serve { serve_on(version, auth, ScriptedSession::per_stream(vec![first_script])) } /// [`serve_with_auth`] over a given session, such as one that finishes the stream after /// its script. - fn serve_on(version: Version, auth: crate::auth::Handle, session: ScriptedSession) -> Serve { + fn serve_on(version: Version, auth: crate::auth::Link, session: ScriptedSession) -> Serve { let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); let broadcast = origin.publish("room", crate::origin::Route::default()).unwrap(); let track = broadcast.create_track("video", crate::track::Info::timed()).unwrap(); @@ -12233,13 +12233,13 @@ mod serve_tests { /// An auth handle whose session grant covers only `other`, so a SUBSCRIBE for `room` /// falls to its request token. The presented credential is kept alive by the returned /// [`crate::auth::Auth`]. - fn auth_covering_other() -> (crate::auth::Handle, crate::auth::Requests, crate::auth::Auth) { - let auth = crate::auth::Handle::new(true); - let requests = auth.requests(); + fn auth_covering_other() -> (crate::auth::Link, crate::auth::Requests, crate::auth::Auth) { + let auth = crate::auth::Link::new(true); + let requests = auth.handle().requests(); let cred = auth.present(bytes::Bytes::from_static(b"cred"), true).unwrap(); // The peer lets us publish everything, and we let it read only "other". auth.granted(0, crate::auth::Grant::all()); - auth.authorize(&crate::auth::Grant { + auth.handle().authorize(&crate::auth::Grant { publish: crate::Patterns::new(), subscribe: crate::Pattern::subtree("other").unwrap().into(), expires: None, @@ -13474,9 +13474,9 @@ mod serve_tests { #[moq_clock::test] async fn a_request_token_on_a_no_auth_session_is_verified() { const VERSION: Version = Version::Draft18; - let auth = crate::auth::Handle::new(false); - let mut requests = auth.requests(); - auth.authorize(&crate::auth::Grant::default()); + let auth = crate::auth::Link::new(false); + let mut requests = auth.handle().requests(); + auth.handle().authorize(&crate::auth::Grant::default()); let h = serve_with_auth(VERSION, auth, Vec::new()); let mut group = h.track.create_group(group::Info { sequence: 0 }).unwrap(); @@ -13525,12 +13525,12 @@ mod serve_tests { async fn serve_token_subscribe( grant: crate::auth::Grant, limit: Option, - ) -> (crate::auth::Handle, Serve, Serving) { + ) -> (crate::auth::Link, Serve, Serving) { const VERSION: Version = Version::Draft18; - let auth = crate::auth::Handle::new(false); - let mut requests = auth.requests(); + let auth = crate::auth::Link::new(false); + let mut requests = auth.handle().requests(); if let Some(limit) = &limit { - auth.authorize(limit); + auth.handle().authorize(limit); } let h = serve_with_auth(VERSION, auth.clone(), Vec::new()); let mut group = h.track.create_group(group::Info { sequence: 0 }).unwrap(); @@ -13602,7 +13602,7 @@ mod serve_tests { still_serving(&mut serving).await, "a limit of nothing defers to the token" ); - auth.authorize(&crate::auth::Grant { + auth.handle().authorize(&crate::auth::Grant { publish: crate::Patterns::new(), subscribe: crate::Pattern::subtree("other").unwrap().into(), expires: None, @@ -13619,8 +13619,8 @@ mod serve_tests { #[moq_clock::test] async fn a_covering_peer_grant_never_skips_the_verifier() { const VERSION: Version = Version::Draft18; - let auth = crate::auth::Handle::new(true); - let mut requests = auth.requests(); + let auth = crate::auth::Link::new(true); + let mut requests = auth.handle().requests(); let _cred = auth.present(bytes::Bytes::from_static(b"cred"), true).unwrap(); auth.granted(0, crate::auth::Grant::all()); let h = serve_with_auth(VERSION, auth, Vec::new()); @@ -13671,7 +13671,7 @@ mod serve_tests { let (auth, _h, mut serving) = serve_token_subscribe(crate::auth::Grant::all(), Some(limit("room"))).await; assert!(still_serving(&mut serving).await, "the verified token admits it"); - auth.authorize(&limit("other")); + auth.handle().authorize(&limit("other")); assert!( still_serving(&mut serving).await, "narrowing the limit leaves the token-verified subscription" @@ -13683,8 +13683,8 @@ mod serve_tests { #[moq_clock::test] async fn a_token_on_a_subscription_admitted_without_one_is_refused() { for version in [Version::Draft16, Version::Draft22] { - let auth = crate::auth::Handle::new(false); - auth.authorize(&crate::auth::Grant::all()); + let auth = crate::auth::Link::new(false); + auth.handle().authorize(&crate::auth::Grant::all()); let h = serve_with_auth(version, auth, Vec::new()); let session = ScriptedSession::per_stream(vec![update_with_tokens(version, &[TOKEN])]); let mut stream = Stream::open(&mut crate::driver::Transport::new(session.clone()), version) @@ -13742,7 +13742,7 @@ mod serve_tests { a_token_without_a_verifier_is_refused(VERSION, limit).await; } - let unlimited = crate::auth::Handle::new(false); + let unlimited = crate::auth::Link::new(false); let h = serve_with_auth(VERSION, unlimited, Vec::new()); let mut group = h.track.create_group(group::Info { sequence: 0 }).unwrap(); group.write_frame(timestamp(), b"frame".as_slice()).unwrap(); @@ -13763,8 +13763,8 @@ mod serve_tests { /// Serve a token SUBSCRIBE on a session limited to `limit` with no verifier, and check it /// is refused NOT_SUPPORTED. async fn a_token_without_a_verifier_is_refused(version: Version, limit: crate::auth::Grant) { - let auth = crate::auth::Handle::new(false); - auth.authorize(&limit); + let auth = crate::auth::Link::new(false); + auth.handle().authorize(&limit); let h = serve_with_auth(version, auth, Vec::new()); let stream = Stream::open(&mut crate::driver::Transport::new(h.session.clone()), version) .await @@ -13808,8 +13808,8 @@ mod serve_tests { const VERSION: Version = Version::Draft18; // Withdrawn before the application looked. - let auth = crate::auth::Handle::new(false); - let _requests = auth.requests(); + let auth = crate::auth::Link::new(false); + let _requests = auth.handle().requests(); let mut h = serve_with_auth(VERSION, auth, Vec::new()); h.publisher.subscriptions = crate::session::Slots::new(1); let stream = Stream::open(&mut crate::driver::Transport::new(h.session.clone()), VERSION) @@ -13832,8 +13832,8 @@ mod serve_tests { ); // Withdrawn after the application took it. - let auth = crate::auth::Handle::new(false); - let mut requests = auth.requests(); + let auth = crate::auth::Link::new(false); + let mut requests = auth.handle().requests(); let mut h = serve_with_auth(VERSION, auth, Vec::new()); h.publisher.subscriptions = crate::session::Slots::new(1); let stream = Stream::open(&mut crate::driver::Transport::new(h.session.clone()), VERSION) @@ -13875,8 +13875,8 @@ mod serve_tests { #[moq_clock::test] async fn a_cancelled_subscribe_keeps_its_stream_until_the_application_answers() { const VERSION: Version = Version::Draft18; - let auth = crate::auth::Handle::new(false); - let mut requests = auth.requests(); + let auth = crate::auth::Link::new(false); + let mut requests = auth.handle().requests(); let h = serve_with_auth(VERSION, auth, Vec::new()); let stream = Stream::open(&mut crate::driver::Transport::new(h.session.clone()), VERSION) .await @@ -13921,8 +13921,8 @@ mod serve_tests { #[moq_clock::test] async fn a_request_token_cannot_exceed_the_peers_grant() { const VERSION: Version = Version::Draft18; - let auth = crate::auth::Handle::new(true); - let mut requests = auth.requests(); + let auth = crate::auth::Link::new(true); + let mut requests = auth.handle().requests(); let _cred = auth.present(bytes::Bytes::from_static(b"cred"), true).unwrap(); auth.granted( 0, @@ -13932,7 +13932,7 @@ mod serve_tests { expires: None, }, ); - auth.authorize(&crate::auth::Grant::default()); + auth.handle().authorize(&crate::auth::Grant::default()); let h = serve_with_auth(VERSION, auth, Vec::new()); let stream = Stream::open(&mut crate::driver::Transport::new(h.session.clone()), VERSION) .await @@ -15660,7 +15660,7 @@ mod tests { /// `origin` unsolicited, with one script per stream it opens. fn auth_publisher( origin: &crate::origin::Producer, - auth: &crate::auth::Handle, + auth: &crate::auth::Link, scripts: Vec>, version: Version, ) -> (Publisher, crate::lite::test_transport::Log) { @@ -15715,7 +15715,7 @@ mod tests { async fn swaps_a_route_auth(version: Version) { let origin = unheld_origin(); - let auth = crate::auth::Handle::ietf(false); + let auth = crate::auth::Link::ietf(false); let first = auth.present(bytes::Bytes::from_static(b"first-secret"), false).unwrap(); let second = auth .present(bytes::Bytes::from_static(b"second-secret"), false) @@ -15786,7 +15786,7 @@ mod tests { const VERSION: Version = Version::Draft16; let origin = unheld_origin(); - let auth = crate::auth::Handle::ietf(false); + let auth = crate::auth::Link::ietf(false); let first = auth.present(bytes::Bytes::from_static(b"first-secret"), false).unwrap(); let second = auth .present(bytes::Bytes::from_static(b"second-secret"), false) @@ -15829,7 +15829,7 @@ mod tests { const VERSION: Version = Version::Draft16; let origin = unheld_origin(); - let auth = crate::auth::Handle::ietf(false); + let auth = crate::auth::Link::ietf(false); let first = auth.present(bytes::Bytes::from_static(b"first-secret"), false).unwrap(); let second = auth .present(bytes::Bytes::from_static(b"second-secret"), false) @@ -15876,7 +15876,7 @@ mod tests { const VERSION: Version = Version::Draft19; let origin = unheld_origin(); - let other = crate::auth::Handle::ietf(false); + let other = crate::auth::Link::ietf(false); let foreign = other .present(bytes::Bytes::from_static(b"foreign-secret"), false) .unwrap(); @@ -15886,7 +15886,7 @@ mod tests { settle().await; let ok = publish_namespace_ok(VERSION).await; - let (publisher, log) = auth_publisher(&origin, &crate::auth::Handle::ietf(false), vec![ok], VERSION); + let (publisher, log) = auth_publisher(&origin, &crate::auth::Link::ietf(false), vec![ok], VERSION); let mut run = std::pin::pin!(publisher.run_publish_namespaces()); poll_until(&mut run, &log, b"cam", 1).await; @@ -15905,7 +15905,7 @@ mod tests { const VERSION: Version = Version::Draft19; let origin = unheld_origin(); - let auth = crate::auth::Handle::ietf(false); + let auth = crate::auth::Link::ietf(false); let token = auth.present(bytes::Bytes::from_static(b"late-secret"), false).unwrap(); let cam = origin.announce("cam", crate::origin::Route::default()).unwrap(); settle().await; @@ -15931,7 +15931,7 @@ mod tests { const VERSION: Version = Version::Draft14; let origin = unheld_origin(); - let auth = crate::auth::Handle::ietf(false); + let auth = crate::auth::Link::ietf(false); let first = auth.present(bytes::Bytes::from_static(b"first-secret"), false).unwrap(); let second = auth .present(bytes::Bytes::from_static(b"second-secret"), false) @@ -15965,7 +15965,7 @@ mod tests { const VERSION: Version = Version::Draft19; let origin = unheld_origin(); - let auth = crate::auth::Handle::ietf(false); + let auth = crate::auth::Link::ietf(false); let present = |token: &'static [u8]| auth.present(bytes::Bytes::from_static(token), false).unwrap(); let a = origin .announce("cam-a", crate::origin::Route::default().with_auth(present(b"a-first"))) @@ -16168,7 +16168,7 @@ mod tests { // A token too big for the header's u16 length, so the header fails after its stream // opened and its request ID was spent. A message goes out whole, so this is the one // failure a fresh in-memory stream can be made to show. - let auth = crate::auth::Handle::ietf(false); + let auth = crate::auth::Link::ietf(false); let token = auth.present(bytes::Bytes::from(vec![0; 70_000]), false).unwrap(); let _cam = origin .announce("cam", crate::origin::Route::default().with_auth(token)) @@ -16233,7 +16233,7 @@ mod tests { const VERSION: Version = Version::Draft19; let origin = unheld_origin(); - let auth = crate::auth::Handle::ietf(false); + let auth = crate::auth::Link::ietf(false); let present = |token: &'static [u8]| auth.present(bytes::Bytes::from_static(token), false).unwrap(); let cam = origin .announce("cam", crate::origin::Route::default().with_auth(present(b"first"))) @@ -16288,7 +16288,7 @@ mod tests { async fn advertises_again_unanswered(version: Version) { let origin = unheld_origin(); - let auth = crate::auth::Handle::ietf(false); + let auth = crate::auth::Link::ietf(false); let present = |token: &'static [u8]| auth.present(bytes::Bytes::from_static(token), false).unwrap(); let cam = origin .announce("cam", crate::origin::Route::default().with_auth(present(b"first"))) diff --git a/rs/moq-net/src/ietf/publisher/tracks.rs b/rs/moq-net/src/ietf/publisher/tracks.rs index 8aba90df202..3d358d6fa6a 100644 --- a/rs/moq-net/src/ietf/publisher/tracks.rs +++ b/rs/moq-net/src/ietf/publisher/tracks.rs @@ -2520,7 +2520,7 @@ mod tests { async fn a_listing_waits_for_the_setup_answer() { for version in [Version::Draft18, Version::Draft22] { let mut h = harness(version); - let auth = crate::auth::Handle::new(true); + let auth = crate::auth::Link::new(true); let _cred = auth.present(bytes::Bytes::from_static(b"cred"), true).unwrap(); let setup = peer::PeerSetup::default(); setup.set(peer::Peer { @@ -2570,8 +2570,8 @@ mod tests { }; for version in [Version::Draft18, Version::Draft22] { let mut h = harness(version); - let auth = crate::auth::Handle::new(false); - auth.authorize(¬hing()); + let auth = crate::auth::Link::new(false); + auth.handle().authorize(¬hing()); h.publisher = h.publisher.clone().with_auth(auth.clone()); let mut request = h.request(&listing(false)).await; @@ -2583,13 +2583,13 @@ mod tests { ))); assert!(opened.is_pending(), "{version}: listed a track the grant denies"); - auth.authorize(&crate::auth::Grant::all()); + auth.handle().authorize(&crate::auth::Grant::all()); let (mut first, ..) = h.publish().await; let (mut second, ..) = h.publish().await; publish_ok(&mut first).await; publish_ok(&mut second).await; - auth.authorize(¬hing()); + auth.handle().authorize(¬hing()); for stream in [&mut first, &mut second] { assert_eq!(next(stream).await.0, ietf::PublishDone::ID, "{version}"); } diff --git a/rs/moq-net/src/ietf/session.rs b/rs/moq-net/src/ietf/session.rs index 60abd9277dc..acd7861971b 100644 --- a/rs/moq-net/src/ietf/session.rs +++ b/rs/moq-net/src/ietf/session.rs @@ -81,7 +81,7 @@ pub struct Config { /// peer's token requests during its handshake. Supports AUTH exactly when the /// version can negotiate it and this side offers it; the peer's SETUP decides whether /// it does. - pub auth: crate::auth::Handle, + pub auth: crate::auth::Link, /// Uni streams that arrived before that pre-read SETUP (see [`PeerSetup::early`]), /// classified by the session before it accepts any more. @@ -507,7 +507,6 @@ pub fn start(config: Config) -> Result<(Driver, crate::goaway::Handle), Error> { auth.clone(), peer_setup.clone(), version, - goaway.going_away.clone(), stats.clone(), ); // Only a side that offered MoQ Auth answers the peer's Auth requests: one that @@ -1562,7 +1561,7 @@ async fn read_goaways( /// Closes the auth handle when the session driver ends without finishing, releasing /// anything still waiting on a token. -struct AuthClose(crate::auth::Handle); +struct AuthClose(crate::auth::Link); impl Drop for AuthClose { fn drop(&mut self) { @@ -1575,7 +1574,7 @@ impl Drop for AuthClose { /// /// Judges the publisher's serving origin rather than the raw one: an origin that also /// consumes holds what the peer sent us, which is not ours to publish back. -async fn enforce_grant(auth: crate::auth::Handle, publisher: Publisher, mut session: Transport) -> Result<(), Error> { +async fn enforce_grant(auth: crate::auth::Link, publisher: Publisher, mut session: Transport) -> Result<(), Error> { let origin = publisher.serving_origin().await; // Checking what we publish is not a local reader asking to discover anything. let mut announced = origin.clone().serving().announced(); @@ -1655,7 +1654,7 @@ mod tests { solicit: Some(true), ..Default::default() }), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), early_unis: Vec::new(), implementation: None, peer_implementation: Default::default(), @@ -1758,7 +1757,7 @@ mod tests { auth: true, ..Default::default() }), - auth: crate::auth::Handle::new(true), + auth: crate::auth::Link::new(true), early_unis: Vec::new(), implementation: None, peer_implementation: Default::default(), @@ -1816,7 +1815,7 @@ mod tests { auth: true, ..Default::default() }), - auth: crate::auth::Handle::new(true), + auth: crate::auth::Link::new(true), early_unis: Vec::new(), implementation: None, peer_implementation: Default::default(), @@ -1865,7 +1864,7 @@ mod tests { auth: true, ..Default::default() }), - auth: crate::auth::Handle::new(true), + auth: crate::auth::Link::new(true), early_unis: Vec::new(), implementation: None, peer_implementation: Default::default(), @@ -1974,7 +1973,7 @@ mod tests { }, ..Default::default() }), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), early_unis: Vec::new(), implementation: None, peer_implementation: Default::default(), @@ -2033,7 +2032,7 @@ mod tests { peer_setup_stream: None, // The requests wait on the peer's SETUP (MoQ Hidden). peer_declared: Some(peer::Peer::default()), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), early_unis: Vec::new(), implementation: None, peer_implementation: Default::default(), @@ -2115,7 +2114,7 @@ mod tests { solicit, ..Default::default() }), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), early_unis: Vec::new(), implementation: None, peer_implementation: Default::default(), @@ -2203,7 +2202,7 @@ mod tests { authority: None, peer_setup_stream: None, peer_declared, - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), early_unis: Vec::new(), implementation: None, peer_implementation: Default::default(), @@ -2274,7 +2273,7 @@ mod tests { /// A publishing draft-18 session with AUTH available locally, against a peer that /// declared `auth`. Everything the session needs to keep running is held here. struct AuthSession { - handle: crate::auth::Handle, + handle: crate::auth::Link, log: crate::lite::test_transport::Log, _origin: crate::origin::Producer, _cam: crate::AnnounceProducer, @@ -2291,7 +2290,7 @@ mod tests { let session = crate::lite::test_transport::SinkSession::gated_bi(gate.consume()); let log = session.log.clone(); - let handle = crate::auth::Handle::ietf(true); + let handle = crate::auth::Link::ietf(true); let (driver, goaway) = start(Config { runtime: crate::time::sim(), stats: Default::default(), @@ -2338,12 +2337,13 @@ mod tests { let session = auth_session(false); let (handle, log) = (&session.handle, &session.log); - let auth = moq_clock::timeout(std::time::Duration::from_secs(1), handle.add("token")) + let (auth, answer) = moq_clock::timeout(std::time::Duration::from_secs(1), handle.handle().add("token")) .await .expect("resolved promptly") .expect("kept for requests"); + assert_eq!(answer, crate::auth::Answer::Requests); assert_eq!(auth.grant().peek(), None, "no grant without the extension"); - assert_eq!(handle.grant().peek(), None, "no grant without the extension"); + assert_eq!(handle.handle().grant().peek(), None, "no grant without the extension"); for _ in 0..ANNOUNCE_TURNS { if occurrences(log, b"solo-cam") > 0 { @@ -2374,7 +2374,7 @@ mod tests { } /// What the draft-18 SETUP that `start` writes says about MoQ Auth and MoQ Solicit. - async fn our_setup(client: bool, auth: crate::auth::Handle, extensions: crate::setup::Extensions) -> (bool, bool) { + async fn our_setup(client: bool, auth: crate::auth::Link, extensions: crate::setup::Extensions) -> (bool, bool) { const VERSION: Version = Version::Draft18; let session = crate::lite::test_transport::ScriptedSession::new(Vec::new()); @@ -2428,13 +2428,13 @@ mod tests { no_solicit.solicit = false; // A client's handle supports AUTH exactly when it offers it. - assert_eq!(our_setup(true, crate::auth::Handle::new(true), all).await, (true, true)); + assert_eq!(our_setup(true, crate::auth::Link::new(true), all).await, (true, true)); assert_eq!( - our_setup(true, crate::auth::Handle::new(false), no_auth).await, + our_setup(true, crate::auth::Link::new(false), no_auth).await, (false, true) ); assert_eq!( - our_setup(true, crate::auth::Handle::new(true), no_solicit).await, + our_setup(true, crate::auth::Link::new(true), no_solicit).await, (true, false) ); } @@ -2444,12 +2444,9 @@ mod tests { #[moq_clock::test] async fn a_server_offers_auth_only_to_a_client_that_did() { let all = crate::setup::Extensions::default(); + assert_eq!(our_setup(false, crate::auth::Link::new(true), all).await, (true, true)); assert_eq!( - our_setup(false, crate::auth::Handle::new(true), all).await, - (true, true) - ); - assert_eq!( - our_setup(false, crate::auth::Handle::new(false), all).await, + our_setup(false, crate::auth::Link::new(false), all).await, (false, true) ); } @@ -2502,7 +2499,7 @@ mod tests { ..Default::default() }), // As `Server` builds it: offered by the client, so supported iff we offer it. - auth: crate::auth::Handle::new(offered), + auth: crate::auth::Link::new(offered), early_unis: Vec::new(), implementation: None, peer_implementation: Default::default(), @@ -2592,7 +2589,7 @@ mod tests { // Pre-settled, so nothing waits on a SETUP the dead stream will never // carry and the dispatch loop actually runs. peer_declared: Some(peer::Peer::default()), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), early_unis: Vec::new(), implementation: None, peer_implementation: Default::default(), @@ -2636,7 +2633,7 @@ mod tests { authority: Some(String::from_utf8(AUTHORITY.to_vec()).unwrap()), peer_setup_stream: None, peer_declared: Some(peer::Peer::default()), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), early_unis: Vec::new(), implementation: None, peer_implementation: Default::default(), @@ -2908,7 +2905,7 @@ mod tests { authority: None, peer_setup_stream: None, peer_declared: Some(peer::Peer::default()), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), early_unis: Vec::new(), implementation: None, peer_implementation: Default::default(), @@ -3056,7 +3053,7 @@ mod tests { authority: None, peer_setup_stream: None, peer_declared: Some(peer::Peer::default()), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), early_unis: Vec::new(), implementation: None, peer_implementation: Default::default(), @@ -3126,7 +3123,7 @@ mod tests { authority: None, peer_setup_stream: None, peer_declared: Some(peer::Peer::default()), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), early_unis: Vec::new(), implementation: None, peer_implementation: Default::default(), @@ -3236,7 +3233,7 @@ mod tests { authority: None, peer_setup_stream: None, peer_declared: None, - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), early_unis: Vec::new(), implementation: None, peer_implementation: Default::default(), @@ -3366,7 +3363,7 @@ mod tests { authority: None, peer_setup_stream: None, peer_declared: Some(peer::Peer::default()), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), early_unis: Vec::new(), implementation: None, peer_implementation: Default::default(), @@ -3607,7 +3604,7 @@ mod tests { authority: None, peer_setup_stream: None, peer_declared: None, - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), early_unis: Vec::new(), implementation: None, peer_implementation: Default::default(), @@ -3713,7 +3710,7 @@ mod tests { authority: None, peer_setup_stream: None, peer_declared: Some(peer::Peer::default()), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), early_unis: Vec::new(), implementation: None, peer_implementation: Default::default(), diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index 6296918585a..35d4024509a 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -797,7 +797,7 @@ pub(super) struct Subscriber { // fail requests that land before the replacement is up. going_away: crate::goaway::GoingAway, // Our grant (MoQ Auth): a subscription it stops covering is cancelled. - auth: crate::auth::Handle, + auth: crate::auth::Link, // Namespaces the peer may have announced at once (`session::Limits::announces`), and // tracks it lists to us. pub(super) announces: crate::session::Slots, @@ -896,7 +896,7 @@ impl Subscriber { tasks, version, going_away, - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), announces: Default::default(), listings: Default::default(), validity: Default::default(), @@ -906,7 +906,7 @@ impl Subscriber { /// Bound what we subscribe to by the grant this session's tokens earn (MoQ Auth), /// and what the peer may publish to us by the session's limit, as either changes. - pub fn with_auth(mut self, auth: crate::auth::Handle) -> Self { + pub fn with_auth(mut self, auth: crate::auth::Link) -> Self { self.auth = auth; let this = self.clone(); self.tasks.push(async move { this.run_limit().await }); @@ -7221,13 +7221,13 @@ mod tests { ) -> ( Subscriber, crate::lite::test_transport::Log, - crate::auth::Handle, + crate::auth::Link, Vec, crate::util::TaskSet, ) { let session = crate::lite::test_transport::ScriptedSession::new(Vec::new()); let log = session.log.clone(); - let auth = crate::auth::Handle::new(true); + let auth = crate::auth::Link::new(true); let setup = auth.present(bytes::Bytes::new(), true).unwrap(); auth.granted(setup.id(), crate::auth::Grant::default()); let unanswered = auth.present(bytes::Bytes::from_static(b"new"), false).unwrap(); @@ -7395,8 +7395,8 @@ mod tests { /// A subscriber whose grant from the peer covers everything until `auth.ended(0, ..)` /// revokes it, with the token handle to hold meanwhile. - fn revocable_auth() -> (crate::auth::Handle, crate::auth::Auth) { - let auth = crate::auth::Handle::new(true); + fn revocable_auth() -> (crate::auth::Link, crate::auth::Auth) { + let auth = crate::auth::Link::new(true); let cred = auth.present(bytes::Bytes::from_static(b"cred"), true).unwrap(); auth.granted(0, crate::auth::Grant::all()); (auth, cred) @@ -7706,7 +7706,7 @@ mod tests { Error::Unauthorized } Invalidated::Narrowed => { - auth.authorize(&crate::auth::Grant::default()); + auth.handle().authorize(&crate::auth::Grant::default()); Error::Unauthorized } // The session's AUTH handler reports expiry by ending the peer's grant. @@ -10204,8 +10204,8 @@ mod tests { // Admitted by its own token, which holds the one slot for the request's life. let path = crate::Path::new("room/host").to_owned(); let hold: crate::auth::Hold = std::sync::Arc::new(subscriber.announces.acquire().unwrap()); - let auth = crate::auth::Handle::new(true); - let mut requests = auth.requests(); + let auth = crate::auth::Link::new(true); + let mut requests = auth.handle().requests(); let token = crate::setup::Token { kind: crate::setup::Token::OUT_OF_BAND, value: vec![0x00, 0xff], @@ -10679,8 +10679,8 @@ mod tests { let peer_setup = peer::PeerSetup::default(); peer_setup.set(peer::Peer::default()); - let auth = crate::auth::Handle::new(true); - let requests = auth.requests(); + let auth = crate::auth::Link::new(true); + let requests = auth.handle().requests(); let cred = auth.present(bytes::Bytes::from_static(b"cred"), true).unwrap(); auth.granted( 0, @@ -10691,7 +10691,7 @@ mod tests { }, ); // We let the peer announce only "other". - auth.authorize(&crate::auth::Grant { + auth.handle().authorize(&crate::auth::Grant { publish: crate::Pattern::subtree("other").unwrap().into(), subscribe: crate::Patterns::new(), expires: None, @@ -11013,7 +11013,7 @@ mod tests { #[moq_clock::test] async fn a_covering_peer_grant_never_skips_the_verifier() { const VERSION: Version = Version::Draft18; - let auth = crate::auth::Handle::new(true); + let auth = crate::auth::Link::new(true); let _cred = auth.present(bytes::Bytes::from_static(b"cred"), true).unwrap(); auth.granted(0, crate::auth::Grant::all()); let (mut subscriber, mut requests, consumer, session) = announce_harness_with(VERSION, auth); @@ -11068,8 +11068,8 @@ mod tests { subscribe: crate::Patterns::new(), expires: None, }; - let auth = crate::auth::Handle::new(false); - auth.authorize(&limit("room")); + let auth = crate::auth::Link::new(false); + auth.handle().authorize(&limit("room")); let (mut subscriber, mut requests, consumer, session) = announce_harness_with(VERSION, auth.clone()); let stream = Stream::open(&mut crate::driver::Transport::new(session.clone()), VERSION) .await @@ -11108,7 +11108,7 @@ mod tests { "the verifier ran though the limit covers the announce" ); - auth.authorize(&limit("other")); + auth.handle().authorize(&limit("other")); for _ in 0..50 { let _ = futures::poll!(acceptor.as_mut()); assert!(futures::poll!(run.as_mut()).is_pending(), "the announce ended"); @@ -11125,8 +11125,8 @@ mod tests { #[moq_clock::test] async fn a_token_authorized_announce_is_exempt_from_the_limit() { const VERSION: Version = Version::Draft18; - let auth = crate::auth::Handle::new(false); - auth.authorize(&crate::auth::Grant::default()); + let auth = crate::auth::Link::new(false); + auth.handle().authorize(&crate::auth::Grant::default()); let (mut subscriber, mut requests, consumer, session) = announce_harness_with(VERSION, auth.clone()); let stream = Stream::open(&mut crate::driver::Transport::new(session.clone()), VERSION) .await @@ -11155,7 +11155,7 @@ mod tests { } assert!(routed, "the token admits the announce outside the limit"); - auth.authorize(&crate::auth::Grant { + auth.handle().authorize(&crate::auth::Grant { publish: crate::Pattern::subtree("other").unwrap().into(), subscribe: crate::Patterns::new(), expires: None, @@ -11176,8 +11176,8 @@ mod tests { #[moq_clock::test] async fn a_token_authorized_announce_admits_its_subscriptions() { const VERSION: Version = Version::Draft18; - let auth = crate::auth::Handle::new(false); - auth.authorize(&crate::auth::Grant::default()); + let auth = crate::auth::Link::new(false); + auth.handle().authorize(&crate::auth::Grant::default()); // The announce's peer says nothing; the subscription's accepts it. let subscribe_ok = { let log = crate::lite::test_transport::Log::default(); @@ -12018,9 +12018,9 @@ mod tests { origin::Consumer, crate::lite::test_transport::ScriptedSession, ) { - let auth = crate::auth::Handle::new(false); + let auth = crate::auth::Link::new(false); if let Some(limit) = &limit { - auth.authorize(limit); + auth.handle().authorize(limit); } announce_harness_with(version, auth) } @@ -12028,7 +12028,7 @@ mod tests { /// An announce harness over `auth`, set up by the caller, taking its requests. fn announce_harness_with( version: Version, - auth: crate::auth::Handle, + auth: crate::auth::Link, ) -> ( Subscriber, crate::auth::Requests, @@ -12042,7 +12042,7 @@ mod tests { /// An announce harness over `auth` and `session`, both set up by the caller. fn announce_harness_on( version: Version, - auth: crate::auth::Handle, + auth: crate::auth::Link, session: crate::lite::test_transport::ScriptedSession, ) -> ( Subscriber, @@ -12057,7 +12057,7 @@ mod tests { let peer_setup = peer::PeerSetup::default(); peer_setup.set(peer::Peer::default()); - let requests = auth.requests(); + let requests = auth.handle().requests(); let subscriber = Subscriber::new( crate::time::sim(), @@ -15912,7 +15912,7 @@ mod joining_fetch_tests { .iter() .any(|(id, _)| *id == ietf::Fetch::ID) ); - run.subscriber.auth.authorize(&crate::auth::Grant::default()); + run.subscriber.auth.handle().authorize(&crate::auth::Grant::default()); settle().await; let mut received = (&mut run._hold.2) .now_or_never() @@ -15997,7 +15997,7 @@ mod joining_fetch_tests { .count(); assert!(requests > 0, "cache-miss FETCH was sent"); let mut held = accepted.then(|| (&mut fetch).now_or_never().expect("FETCH_OK").unwrap()); - auth.authorize(&crate::auth::Grant::default()); + auth.handle().authorize(&crate::auth::Grant::default()); assert!( futures::poll!(serving.as_mut()).is_pending(), "FIN stays unacknowledged" diff --git a/rs/moq-net/src/lite/publisher.rs b/rs/moq-net/src/lite/publisher.rs index 86d4dc13d91..a0b7edfc15e 100644 --- a/rs/moq-net/src/lite/publisher.rs +++ b/rs/moq-net/src/lite/publisher.rs @@ -45,7 +45,7 @@ pub(super) struct PublisherConfig { pub peer_hop: Option, /// Our tokens' grants, which bound what we announce and serve, and the app's /// claim on answering the peer's tokens. - pub auth: crate::auth::Handle, + pub auth: crate::auth::Link, /// What the default acceptor grants the peer's connection credential. pub peer_grant: crate::auth::Grant, /// The supervisor's estimate, which a PROBE stream follows. @@ -78,7 +78,7 @@ struct Shared { priority: PriorityQueue, version: Version, goaway: crate::goaway::Protocol, - auth: crate::auth::Handle, + auth: crate::auth::Link, peer_grant: crate::auth::Grant, // Control streams still serving the peer data, which a draining close waits for. owed: AtomicUsize, @@ -275,7 +275,7 @@ impl Publisher { announced: &mut announce::Consumer, self_origin: Hop, version: Version, - auth: Option, + auth: Option, ) -> Result<(), Error> { let mut run = AnnounceRun::new(crate::PathOwned::default(), self_origin, version); run.auth = auth.map(|handle| { @@ -2648,8 +2648,8 @@ mod announce_test { let mut run = AnnounceRun::new(crate::Path::new("").to_owned(), consumer.hop(), Version::Lite07); let waiter = kio::Waiter::noop(); assert!(run.poll(&mut stream, &consumer, &mut announced, &waiter).is_pending()); - let auth = crate::auth::Handle::new(false); - auth.authorize(&crate::auth::Grant { + let auth = crate::auth::Link::new(false); + auth.handle().authorize(&crate::auth::Grant { subscribe: crate::Pattern::literal("b").unwrap().into(), ..Default::default() }); @@ -2690,8 +2690,8 @@ mod announce_test { let mut run = AnnounceRun::new(crate::Path::new("").to_owned(), consumer.hop(), Version::Lite07); let waiter = kio::Waiter::noop(); assert!(run.poll(&mut stream, &consumer, &mut announced, &waiter).is_pending()); - let auth = crate::auth::Handle::new(false); - auth.authorize(&crate::auth::Grant::default()); + let auth = crate::auth::Link::new(false); + auth.handle().authorize(&crate::auth::Grant::default()); let Poll::Ready(restricted) = auth.poll_permit(crate::auth::Direction::Publish, &mut 0, &waiter) else { panic!("the changed grant is ready"); }; @@ -2911,7 +2911,7 @@ mod announce_test { } /// [`harness_on`], announcing under `auth`'s grant. - async fn harness_with(version: Version, auth: Option) -> Harness { + async fn harness_with(version: Version, auth: Option) -> Harness { let origin = Hop::new(1).unwrap().produce(); let announcement = origin .announce( @@ -3115,8 +3115,8 @@ mod announce_test { expires: None, }; for anonymous in [false, true] { - let auth = crate::auth::Handle::new(false); - auth.authorize(&grant(&["cam"])); + let auth = crate::auth::Link::new(false); + auth.handle().authorize(&grant(&["cam"])); let mut h = harness_with(Version::Lite07, Some(auth.clone())).await; // Same chain and cost, so only the instance changed. Both land before the loop polls. @@ -3127,7 +3127,7 @@ mod announce_test { None => route, }; let _newer = h.origin.announce("cam", route).unwrap(); - auth.authorize(&grant(&["cam", "other"])); + auth.handle().authorize(&grant(&["cam", "other"])); moq_clock::sleep(crate::origin::Config::default().update_hold).await; settle().await; match h.wire.take_announces().as_slice() { @@ -6931,7 +6931,7 @@ mod tests { peer_setup, goaway, peer_hop: Some(assigned), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), peer_grant: Default::default(), client: false, subscriptions: Default::default(), @@ -7107,7 +7107,7 @@ mod tests { peer_setup: crate::lite::PeerSetup::new(false, crate::transport::Binding::InBand), goaway, peer_hop: None, - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), peer_grant: Default::default(), client: false, subscriptions: Default::default(), @@ -7407,7 +7407,7 @@ mod tests { peer_setup, goaway, peer_hop: None, - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), peer_grant: Default::default(), client: false, subscriptions: Default::default(), @@ -7540,7 +7540,7 @@ mod tests { goaway, peer_hop: None, subscriptions: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), peer_grant: Default::default(), client: false, }); @@ -7633,7 +7633,7 @@ mod tests { peer_setup, goaway, peer_hop: None, - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), peer_grant: Default::default(), client: false, subscriptions: Default::default(), @@ -7752,7 +7752,7 @@ mod tests { goaway, peer_hop: None, subscriptions: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), peer_grant: Default::default(), client: false, }); @@ -7877,7 +7877,7 @@ mod tests { peer_setup, goaway, peer_hop: None, - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), peer_grant: Default::default(), client: false, subscriptions: Default::default(), @@ -8240,7 +8240,7 @@ mod tests { peer_setup, goaway, peer_hop: None, - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), peer_grant: Default::default(), client: false, subscriptions: Default::default(), diff --git a/rs/moq-net/src/lite/session.rs b/rs/moq-net/src/lite/session.rs index aa816abad48..22fc3155791 100644 --- a/rs/moq-net/src/lite/session.rs +++ b/rs/moq-net/src/lite/session.rs @@ -22,7 +22,7 @@ pub(crate) struct SessionStart { /// The session-side GOAWAY halves, stored on the public [`crate::Session`]. pub goaway: crate::goaway::Handle, /// The session's AUTH tokens and grants, stored on the public [`crate::Session`]. - pub auth: crate::auth::Handle, + pub auth: crate::auth::Link, /// The implementation the peer's SETUP advertised, stored on the public [`crate::Session`]. pub peer_implementation: crate::setup::PeerImplementation, } @@ -193,7 +193,7 @@ pub struct Config { /// The session's auth handle, created before [`start`] so a server can take the /// peer's token requests during its handshake. Supports AUTH exactly when `version` /// does. - pub auth: crate::auth::Handle, + pub auth: crate::auth::Link, /// What the peer may make this session hold. pub limits: crate::session::Limits, } @@ -357,7 +357,6 @@ pub fn start(config: Config) -> Result { session: session.clone(), version, handle: auth.clone(), - going_away: goaway.going_away.clone(), tokens: kio::Tasks::new(), violation: Default::default(), started: false, @@ -509,8 +508,7 @@ struct Present { runtime: crate::time::Deadlines, session: Transport, version: Version, - handle: crate::auth::Handle, - going_away: crate::goaway::GoingAway, + handle: crate::auth::Link, tokens: kio::Tasks, /// Set by a token whose peer broke the protocol, which ends the session. violation: kio::Shared>, @@ -536,10 +534,9 @@ impl Present { session: self.session.clone(), version: self.version, handle: self.handle.clone(), - going_away: self.going_away.clone(), violation: self.violation.clone(), id, - state: PresentState::Open { token }, + state: PresentState::Open { token, answered: false }, }); } let _ = self.tokens.poll(waiter); @@ -558,8 +555,7 @@ struct PresentToken { runtime: crate::time::Deadlines, session: Transport, version: Version, - handle: crate::auth::Handle, - going_away: crate::goaway::GoingAway, + handle: crate::auth::Link, violation: kio::Shared>, id: u64, state: PresentState, @@ -568,11 +564,18 @@ struct PresentToken { enum PresentState { Open { token: bytes::Bytes, + /// The peer already replied to this token, so it knows AUTH: a retry's stream lost + /// before its reply is not the peer predating AUTH. + answered: bool, }, Run { stream: Stream, answered: bool, }, + /// Refused for a reason that may pass: present it again once the deadline fires. + Retry { + deadline: crate::time::Deadline, + }, Done, } @@ -581,7 +584,10 @@ impl kio::Task for PresentToken { fn poll(&mut self, waiter: &kio::Waiter) -> Poll<()> { let err = ready!(self.poll_present(waiter)); - let answered = matches!(self.state, PresentState::Run { answered: true, .. }); + let answered = matches!( + self.state, + PresentState::Open { answered: true, .. } | PresentState::Run { answered: true, .. } + ); if let PresentState::Run { stream, .. } = std::mem::replace(&mut self.state, PresentState::Done) { stream.writer.abort(&Error::Cancel); } @@ -614,12 +620,13 @@ impl PresentToken { let mut cx = Context::from_waker(waiter.waker()); loop { match &mut self.state { - PresentState::Open { token } => { - // After a GOAWAY the peer must not see new streams. - if self.going_away.is_set() { + PresentState::Open { token, answered } => { + // After a GOAWAY, sent or received, the peer must not see new streams. + if !self.handle.active() { return Poll::Ready(Error::GoingAway); } let token = token.clone(); + let answered = *answered; let mut stream = match ready!(Stream::poll_open_request(&mut self.session, self.version, &mut cx)) { Ok(stream) => stream, Err(err) => return Poll::Ready(err), @@ -632,10 +639,7 @@ impl PresentToken { if let Err(err) = res { return Poll::Ready(err); } - self.state = PresentState::Run { - stream, - answered: false, - }; + self.state = PresentState::Run { stream, answered }; } PresentState::Run { stream, answered } => { if let Err(err) = ready!(stream.writer.poll_flush(&mut cx)) { @@ -686,10 +690,30 @@ impl PresentToken { self.handle.refused(self.id); } *answered = true; - return Poll::Ready(err); + let Some(delay) = self.handle.retry(self.id, &err) else { + return Poll::Ready(err); + }; + tracing::debug!(?delay, "auth token refused transiently; presenting it again"); + let retry = PresentState::Retry { + deadline: crate::time::Deadline::after(&self.runtime, delay), + }; + if let PresentState::Run { stream, .. } = std::mem::replace(&mut self.state, retry) { + stream.writer.abort(&Error::Cancel); + } } } } + PresentState::Retry { deadline } => { + if self.handle.poll_withdrawn(self.id, waiter).is_ready() { + return Poll::Ready(Error::Cancel); + } + ready!(deadline.poll(waiter)); + let token = match self.handle.reopen(self.id) { + Ok(token) => token, + Err(err) => return Poll::Ready(err), + }; + self.state = PresentState::Open { token, answered: true }; + } PresentState::Done => return Poll::Ready(Error::Cancel), } } @@ -964,7 +988,7 @@ mod tests { version: Version::Lite07, our_setup: Setup::default(), peer_setup: None, - auth: crate::auth::Handle::new(true), + auth: crate::auth::Link::new(true), }) .unwrap(); let mut driver = start.driver; @@ -979,6 +1003,63 @@ mod tests { assert_eq!(log.closes()[0].0, SessionError::ProtocolViolation.to_code()); } + /// A retry's stream that ends before its reply is lost, not a peer without AUTH: the + /// peer already answered the token, so a shared handle keeps it for the next session. + #[moq_clock::test] + async fn a_lost_retry_keeps_a_shared_token() { + let version = Version::Lite07; + let mut grant = Vec::new(); + let mut w = crate::coding::Encoder::new(&mut grant, version.into()); + w.varint(0).unwrap(); + let prefix = w.prefix_varint(); + for _ in 0..3 { + w.varint(0).unwrap(); + } + w.fill(prefix).unwrap(); + let mut busy = Vec::new(); + let mut w = crate::coding::Encoder::new(&mut busy, version.into()); + w.varint(1).unwrap(); + let prefix = w.prefix_varint(); + w.varint(SessionError::TooManyRequests.to_code().into()).unwrap(); + w.bytes(b"busy").unwrap(); + w.fill(prefix).unwrap(); + + // The setup token is granted, ours is refused transiently, and its retry's stream ends. + let transport = ScriptedSession::per_stream_eof(vec![grant, busy, Vec::new()]); + let log = transport.log.clone(); + let handle = crate::auth::Handle::new(); + let start = start(Config { + runtime: crate::time::sim(), + stats: Default::default(), + client: true, + limits: Default::default(), + session: crate::driver::Transport::new(transport), + setup_stream: None, + publish: None, + subscribe: None, + peer_hop: None, + version, + our_setup: Setup::default(), + peer_setup: None, + auth: crate::auth::Link::attach(&handle, true, false).unwrap(), + }) + .unwrap(); + let mut driver = start.driver; + let token = handle.present(bytes::Bytes::from_static(b"token")).unwrap(); + + let res = moq_clock::timeout( + std::time::Duration::from_secs(5), + kio::wait(|waiter| driver.poll(waiter)), + ) + .await; + assert!(res.is_err(), "the session ended: {res:?}"); + assert_eq!(log.bi_opens(), 3, "the token was not presented again"); + assert!( + futures::FutureExt::now_or_never(token.closed()).is_none(), + "a lost retry ended the token" + ); + } + #[moq_clock::test] async fn malformed_presenter_auth_closes_the_session() { for script in [vec![7, 1, 2], vec![7, 1, 0, 0]] { @@ -997,7 +1078,7 @@ mod tests { version: Version::Lite07, our_setup: Setup::default(), peer_setup: None, - auth: crate::auth::Handle::new(true), + auth: crate::auth::Link::new(true), }) .unwrap(); let mut driver = start.driver; @@ -1084,7 +1165,7 @@ mod tests { version: Version::Lite07, our_setup, peer_setup: None, - auth: crate::auth::Handle::new(true), + auth: crate::auth::Link::new(true), }) .unwrap(); let mut driver = start.driver; @@ -1155,7 +1236,7 @@ mod tests { our_setup: Setup::default(), peer_setup: None, client: false, - auth: crate::auth::Handle::new(version.has_auth()), + auth: crate::auth::Link::new(version.has_auth()), limits: Default::default(), }) .unwrap(); @@ -1210,7 +1291,7 @@ mod tests { our_setup: Setup::default(), peer_setup: None, client: false, - auth: crate::auth::Handle::new(version.has_auth()), + auth: crate::auth::Link::new(version.has_auth()), limits: Default::default(), }) .unwrap(); @@ -1243,7 +1324,7 @@ mod tests { version, our_setup: Setup::default(), peer_setup, - auth: crate::auth::Handle::new(version.has_auth()), + auth: crate::auth::Link::new(version.has_auth()), limits: Default::default(), }) .unwrap() diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index 3a7acad596b..1e8342f8d2f 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -44,7 +44,7 @@ pub(super) struct SubscriberConfig { /// [`crate::origin::Cost::DRAIN`], so a replacement session outranks it. pub going_away: crate::goaway::GoingAway, /// Our tokens' grants: a subscription the union stops covering is cancelled. - pub auth: crate::auth::Handle, + pub auth: crate::auth::Link, } #[derive(Clone)] @@ -87,7 +87,7 @@ pub(super) struct Subscriber { /// [`SourceServe`] machines. sources: kio::Queue, going_away: crate::goaway::GoingAway, - auth: crate::auth::Handle, + auth: crate::auth::Link, /// Broadcasts the peer may have announced at once (`session::Limits::announces`). pub(super) announces: crate::session::Slots, } @@ -2673,7 +2673,7 @@ mod tests { peer_hop: None, cost: None, going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); let mut uni = UniServe { subscriber, @@ -2809,7 +2809,7 @@ mod tests { peer_hop: None, cost: None, going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); // The track is untimed where the subscriber drops received timestamps. let track = track::Producer::new( @@ -2869,7 +2869,7 @@ mod tests { peer_hop: None, cost: None, going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); let mut track = track::Producer::new( @@ -2960,7 +2960,7 @@ mod tests { peer_hop: None, cost: None, going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); let pool = crate::cache::Pool::unbounded(); let mut info = crate::track::Info::default().with_timescale(framing.timescale); @@ -3146,7 +3146,7 @@ mod tests { peer_hop: None, cost: None, going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); // The copy is untimed where the wire's timestamps may be send times. @@ -3259,7 +3259,7 @@ mod tests { peer_hop: None, cost: None, going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); let track = track::Producer::new( @@ -3331,7 +3331,7 @@ mod tests { peer_hop: None, cost: None, going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); let serve = TrackServe { validity: Default::default(), @@ -3403,7 +3403,7 @@ mod tests { peer_hop: None, cost: None, going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); let serve = TrackServe { validity: Default::default(), @@ -3473,7 +3473,7 @@ mod tests { peer_hop: None, cost: None, going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); let serve = TrackServe { validity: Default::default(), @@ -3532,7 +3532,7 @@ mod tests { peer_hop: None, cost: None, going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); let serve = TrackServe { validity: Default::default(), @@ -3631,7 +3631,7 @@ mod tests { peer_hop: None, cost: None, going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); let broadcast = crate::broadcast::Info::new().produce(); @@ -3704,7 +3704,7 @@ mod tests { peer_hop: None, cost: None, going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); let broadcast = crate::broadcast::Info::new().produce(); @@ -3757,7 +3757,7 @@ mod tests { cost: Some(1), peer_hop: None, going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); let mut driver = SubscriberDriver::new(subscriber, Vec::new(), false); assert!(driver.poll(&kio::Waiter::noop()).is_pending()); @@ -3799,7 +3799,7 @@ mod tests { peer_hop: None, cost: None, going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); let broadcast = crate::broadcast::Info::new().produce(); @@ -3841,7 +3841,7 @@ mod tests { /// route that keeps it served. fn listed_source( session: SinkSession, - auth: crate::auth::Handle, + auth: crate::auth::Link, validity: crate::model::broadcast::RequestValidity, ) -> (SourceServe, crate::broadcast::Consumer, kio::Producer<()>) { let subscriber = Subscriber::new(SubscriberConfig { @@ -3878,7 +3878,7 @@ mod tests { /// syncing its readers or asking the peer, which would refuse it until then. #[moq_clock::test] async fn a_listing_waits_for_its_token() { - let auth = crate::auth::Handle::new(true); + let auth = crate::auth::Link::new(true); let token = auth.present(bytes::Bytes::from_static(b"new"), false).unwrap(); let open = kio::Producer::new(true); let session = SinkSession::gated_bi(open.consume()); @@ -3905,7 +3905,7 @@ mod tests { /// request without an epoch would list its replacement. #[moq_clock::test] async fn a_listing_of_a_retired_instance_never_reaches_the_wire() { - let auth = crate::auth::Handle::new(true); + let auth = crate::auth::Link::new(true); let token = auth.present(bytes::Bytes::from_static(b"new"), false).unwrap(); let open = kio::Producer::new(true); let session = SinkSession::gated_bi(open.consume()); @@ -3925,8 +3925,8 @@ mod tests { /// A listing outside the session's scope is refused before it reaches the wire. #[moq_clock::test] async fn a_listing_outside_the_scope_is_refused() { - let auth = crate::auth::Handle::new(false); - auth.authorize(&crate::auth::Grant::default()); + let auth = crate::auth::Link::new(false); + auth.handle().authorize(&crate::auth::Grant::default()); let open = kio::Producer::new(true); let session = SinkSession::gated_bi(open.consume()); let (mut serve, consumer, _route) = listed_source(session.clone(), auth, Default::default()); @@ -3941,7 +3941,7 @@ mod tests { /// what the session no longer allows, and leaves the session up. #[moq_clock::test] async fn a_narrower_limit_resets_a_listing() { - let auth = crate::auth::Handle::new(false); + let auth = crate::auth::Link::new(false); let open = kio::Producer::new(true); let session = SinkSession::gated_bi(open.consume()); let (mut serve, consumer, _route) = listed_source(session.clone(), auth.clone(), Default::default()); @@ -3954,7 +3954,7 @@ mod tests { Some(Ok(Some(crate::broadcast::TracksEvent::Synced))) )); - auth.authorize(&crate::auth::Grant::default()); + auth.handle().authorize(&crate::auth::Grant::default()); assert!(kio::Task::poll(&mut serve, &kio::Waiter::noop()).is_pending()); assert!(matches!(tracks.next().now_or_never(), Some(Err(Error::Unauthorized)))); assert_eq!(session.log.resets(), vec![crate::StreamError::Unauthorized.to_code()]); @@ -3965,7 +3965,7 @@ mod tests { /// holding its task and track until a token that may never be answered. #[moq_clock::test] async fn an_unused_track_stops_waiting_for_auth() { - let auth = crate::auth::Handle::new(true); + let auth = crate::auth::Link::new(true); let _token = auth.present(bytes::Bytes::from_static(b"new"), false).unwrap(); let subscriber = Subscriber::new(SubscriberConfig { runtime: crate::time::sim(), @@ -4036,7 +4036,7 @@ mod tests { peer_hop: None, cost: None, going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); let subscribes = subscriber.subscribes.clone(); let serve = TrackServe { @@ -4090,7 +4090,7 @@ mod tests { // Writes park, so the SUBSCRIBE is on an open stream but not yet flushed. let gate = kio::Producer::new(false); let session = SinkSession::gated_bi(gate.consume()); - let auth = crate::auth::Handle::new(false); + let auth = crate::auth::Link::new(false); let origin = origin::Config::new(crate::Hop::new(1).unwrap()).produce(); let subscriber = Subscriber::new(SubscriberConfig { @@ -4130,7 +4130,7 @@ mod tests { assert!(kio::Task::poll(&mut running, &kio::Waiter::noop()).is_pending()); assert_eq!(session.log.bi_opens(), 1, "the SUBSCRIBE stream is open"); - auth.authorize(&crate::auth::Grant::default()); + auth.handle().authorize(&crate::auth::Grant::default()); assert!(kio::Task::poll(&mut running, &kio::Waiter::noop()).is_ready()); let unauthorized = crate::StreamError::Unauthorized.to_code(); @@ -4166,7 +4166,7 @@ mod tests { peer_hop: None, cost: None, going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); let broadcast = crate::broadcast::Info::new().produce(); let producer = broadcast @@ -4235,7 +4235,7 @@ mod tests { peer_hop: None, cost: None, going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); let serve = TrackServe { validity: Default::default(), @@ -4280,7 +4280,7 @@ mod tests { peer_hop: None, cost: None, going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); let serve = TrackServe { validity: Default::default(), @@ -4791,7 +4791,7 @@ mod tests { cost: None, peer_hop: Some(assigned), going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); let path = Path::new("room/host").to_owned(); @@ -4846,7 +4846,7 @@ mod tests { cost: None, peer_hop: Some(crate::Hop::new(777).unwrap()), going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); let mut announced = Announced::default(); @@ -4900,7 +4900,7 @@ mod tests { cost: None, peer_hop: Some(crate::Hop::new(777).unwrap()), going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); let path = Path::new("pool").to_owned(); let mut announced = Announced::default(); @@ -4969,7 +4969,7 @@ mod tests { cost: None, peer_hop: Some(crate::Hop::new(777).unwrap()), going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); let mut announced = Announced::default(); subscriber @@ -5025,7 +5025,7 @@ mod tests { cost: None, peer_hop: Some(assigned), going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); let path = Path::new("room/host").to_owned(); @@ -5084,7 +5084,7 @@ mod tests { cost: None, peer_hop: Some(assigned), going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); let path = Path::new("room/host").to_owned(); @@ -5143,7 +5143,7 @@ mod tests { cost: None, peer_hop: Some(assigned), going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); // The sender's identity is already in the chain: the route came back through it. @@ -5210,7 +5210,7 @@ mod tests { cost: None, peer_hop: Some(assigned), going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); // The path as advertised to the peer: our hop is already in the chain. @@ -5270,7 +5270,7 @@ mod tests { peer_hop: Some(assigned), cost: None, going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); // An announce with an empty chain and no responder id: the versions that @@ -5317,7 +5317,7 @@ mod tests { cost: None, peer_hop: Some(assigned), going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); let hops = crate::Hops::try_from(vec![crate::Hop::UNKNOWN, crate::Hop::UNKNOWN]).unwrap(); @@ -5399,7 +5399,7 @@ mod tests { cost: None, peer_hop: None, going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); (subscriber, consumer) } @@ -5467,7 +5467,7 @@ mod tests { let mut cursor = consumer.announced(); cursor.expect_next_active("room/host").await; - auth.authorize(&crate::auth::Grant { + auth.handle().authorize(&crate::auth::Grant { publish: Default::default(), subscribe: [crate::Pattern::subtree("other").unwrap()].into_iter().collect(), expires: None, @@ -5508,7 +5508,7 @@ mod tests { cost: None, peer_hop: None, going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); let path = Path::new("room/host").to_owned(); @@ -5592,7 +5592,7 @@ mod tests { cost: Some(1), peer_hop: None, going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); let mut prefix = AnnouncePrefix::new( subscriber.clone(), @@ -5660,7 +5660,7 @@ mod tests { cost: Some(1), peer_hop: None, going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); ( AnnouncePrefix::new( @@ -5838,7 +5838,7 @@ mod tests { cost: Some(1), peer_hop: None, going_away: Default::default(), - auth: crate::auth::Handle::new(false), + auth: crate::auth::Link::new(false), }); let slots = crate::session::Slots::new(2); subscriber.announces = slots.clone(); diff --git a/rs/moq-net/src/model/origin.rs b/rs/moq-net/src/model/origin.rs index 8f4c53628ac..c40ae1ec478 100644 --- a/rs/moq-net/src/model/origin.rs +++ b/rs/moq-net/src/model/origin.rs @@ -505,14 +505,14 @@ pub struct Route { /// The credential the request advertising this route carries, from /// [`auth::Handle`](crate::auth::Handle)'s `add`, or `None` for the session's scope. /// - /// Only a session the auth was presented on carries it, as the token on its + /// Only a session built on the handle that issued it carries it, as the token on its /// moq-transport PUBLISH_NAMESPACE: any other session, the inline NAMESPACE answering a /// solicited SUBSCRIBE_NAMESPACE from draft 16 on (drafts 14 and 15 answer with a /// PUBLISH_NAMESPACE, which carries it), and moq-lite (which rides the session's scope) /// advertise the route unnamed. Swapping it is an update that renews the request with - /// REQUEST_UPDATE from draft 16, or ends and makes it again before. After a - /// reconnect, add the token on the new session and name that. Local, like - /// [`Self::source`]: a relay never forwards it. + /// REQUEST_UPDATE from draft 16, or ends and makes it again before. A handle passed to + /// `Client::with_auth` presents it on every session it backs, so the route keeps it + /// across a reconnect. Local, like [`Self::source`]: a relay never forwards it. pub auth: Option, /// The announcing session's declared or assigned identity. @@ -8159,7 +8159,7 @@ mod tests { #[moq_clock::test] #[cfg(feature = "async")] async fn an_auth_swap_is_an_update() { - let handle = crate::auth::Handle::ietf(false); + let handle = crate::auth::Link::ietf(false); let first = handle.present(bytes::Bytes::from_static(b"first"), false).unwrap(); let second = handle.present(bytes::Bytes::from_static(b"second"), false).unwrap(); let producer = origin(1).produce(); diff --git a/rs/moq-net/src/server.rs b/rs/moq-net/src/server.rs index 74cffb95192..458c8eaf516 100644 --- a/rs/moq-net/src/server.rs +++ b/rs/moq-net/src/server.rs @@ -167,7 +167,7 @@ impl Server { version: lite::Version, client_setup: Option, peer_hop: Option, - auth: crate::auth::Handle, + auth: crate::auth::Link, ) -> Result<(Session, crate::Driver), Error> { let (publish, subscribe) = self.stat_tagged_origins(); @@ -281,7 +281,7 @@ impl Server { _ => return Err(Error::Version), }; - let auth = crate::auth::Handle::new(match &handshake { + let auth = crate::auth::Link::new(match &handshake { PausedHandshake::LiteBare { version, .. } | PausedHandshake::LiteSetup { version, .. } => { version.has_auth() } @@ -451,8 +451,8 @@ impl Server { // Lite 01/02 and moq-transport 14-16 carry no AUTH, though a moq-transport request // still carries a token. auth: match version { - Version::Ietf(_) => crate::auth::Handle::ietf(false), - Version::Lite(_) => crate::auth::Handle::new(false), + Version::Ietf(_) => crate::auth::Link::ietf(false), + Version::Lite(_) => crate::auth::Link::new(false), }, inner: Some(RequestInner { server: self.clone(), @@ -491,7 +491,7 @@ impl Server { assigned_hop: crate::Hop::random(), // The client's SETUP already settled whether it offered MoQ Auth, so it is // negotiated exactly when we offer it too. - auth: crate::auth::Handle::ietf(peer_setup.declared.auth && self.extensions.auth), + auth: crate::auth::Link::ietf(peer_setup.declared.auth && self.extensions.auth), inner: Some(RequestInner { server: self.clone(), runtime, @@ -525,7 +525,7 @@ pub struct Handshake { assigned_hop: crate::Hop, /// The session's auth handle, available before [`Handshake::ok`] so the caller can /// take the peer's token requests before any arrive. - auth: crate::auth::Handle, + auth: crate::auth::Link, // Taken by `ok`/`close`; `Drop` rejects the handshake if neither ran. inner: Option, } @@ -575,7 +575,7 @@ impl PausedIetfModern { server: Server, runtime: Deadlines, peer_hop: Option, - auth: crate::auth::Handle, + auth: crate::auth::Link, ) -> Result<(Session, crate::Driver), Error> { let Self { session, @@ -654,7 +654,7 @@ impl PausedLegacy { server: Server, runtime: Deadlines, peer_hop: Option, - auth: crate::auth::Handle, + auth: crate::auth::Link, ) -> Result<(Session, crate::Driver), Error> { let Self { session, @@ -830,7 +830,7 @@ impl Handshake { /// The session's auth handle, the same one [`Session::auth`] returns once accepted. pub fn auth(&self) -> crate::auth::Handle { - self.auth.clone() + self.auth.handle().clone() } /// Answer every token the client presents yourself, instead of the default acceptor: @@ -848,7 +848,7 @@ impl Handshake { /// /// [`auth::Requests`]: crate::auth::Requests pub fn requests(&mut self) -> crate::auth::Requests { - self.auth.requests() + self.auth.handle().requests() } /// The credential the client presented in its SETUP's `AUTHORIZATION TOKEN` option. @@ -1662,7 +1662,7 @@ mod tests { token: None, peer_implementation: None, assigned_hop: Hop::random(), - auth: crate::auth::Handle::ietf(false), + auth: crate::auth::Link::ietf(false), inner: Some(RequestInner { server: Server::new().with_publisher(&origin), runtime: Deadlines::new(moq_clock::now()), diff --git a/rs/moq-net/src/session.rs b/rs/moq-net/src/session.rs index f8ad1abfb6d..5be0d444768 100644 --- a/rs/moq-net/src/session.rs +++ b/rs/moq-net/src/session.rs @@ -361,6 +361,9 @@ impl Session { /// The tokens this side presented and the grant they earned, plus the tokens /// the peer presents. See [`auth`]. /// + /// The handle passed to [`Client::with_auth`](crate::Client::with_auth), or else one + /// this session created for itself, which ends with it. + /// /// On moq-lite-07-wip, and on moq-transport draft-17+ when both sides negotiate the /// MoQ Auth extension, each side presents its connection's credential right after /// setup. Older versions, and peers that do not negotiate it, leave the grant `None`. @@ -373,7 +376,7 @@ impl Session { pub(super) struct Handles { pub stats: crate::stats::Session, pub goaway: goaway::Handle, - pub auth: auth::Handle, + pub auth: auth::Link, /// Where the driver publishes [`Estimate`] changes, for a protocol that reports /// them to the peer (moq-lite PROBE). Sampled only while a consumer exists. pub estimate: Option>, @@ -400,6 +403,8 @@ impl Session { bandwidth, peer_implementation, } = handles; + let goaway = Arc::new(goaway); + auth.drain_with(goaway.clone()); let wire = session.wire().map(|counters| context.wire(counters)); let sample = snapshot(&session); if let Some(estimate) = &estimate @@ -444,6 +449,7 @@ impl Session { sampled: None, mode: SamplerMode::Idle, drain: Drain::Idle, + auth: auth.clone(), }; let session = Self { @@ -455,8 +461,8 @@ impl Session { send_bandwidth, recv_bandwidth, bandwidth, - goaway: Arc::new(goaway), - auth, + goaway, + auth: auth.handle().clone(), }; let driver = crate::Driver::new( runtime.clone(), @@ -507,6 +513,9 @@ pub(crate) struct Supervisor { drain: Drain, /// The transport's wire counters, registered under the session's root; dropping records the last reading. wire: Option, + /// This session's side of its auth handle, closed before the end is published so a + /// replacement session built on the same handle never finds this one still active. + auth: auth::Link, } /// A [`Session::close`] waiting for the protocol to deliver what it queued. @@ -560,6 +569,7 @@ impl Supervisor { SessionError::Cancel => Error::Cancel, _ => err, }; + self.auth.close(err.clone()); self.context.end(&err); if let Ok(mut closed) = self.closed.write() { *closed = Some(Ended { err, drain }); diff --git a/rs/moq-net/tests/integration/auth.rs b/rs/moq-net/tests/integration/auth.rs index 6a5497c48b8..91a4de62dc2 100644 --- a/rs/moq-net/tests/integration/auth.rs +++ b/rs/moq-net/tests/integration/auth.rs @@ -70,12 +70,21 @@ cases!( a_live_subscription_survives_union_renewal, a_grant_update_too_large_for_one_message_is_unsupported, nothing_outside_the_grant_reaches_the_peer, + a_shared_handle_presents_its_token_on_every_session, + a_handle_backs_one_active_session, + a_token_queued_before_our_goaway_rides_only_the_successor, a_broadcast_received_from_the_peer_is_not_ours_to_check, exact_and_subtree_grants_admit_their_broadcast, pattern_grants_arrive_exactly, a_wildcard_grant_is_enforced, ); +/// moq-transport's AUTH_ERROR carries no code that may pass, so only lite retries. +#[moq_clock::test] +async fn lite_07_presents_a_transiently_refused_token_again() { + a_transient_refusal_is_presented_again(LITE_07).await +} + /// moq-transport's u16 message size is the one a test can overflow quickly. #[moq_clock::test] async fn moqt_17_a_grant_too_large_for_one_message_is_unsupported() { @@ -142,12 +151,23 @@ async fn moqt_22_route_auth_renews_its_announce() { a_route_auth_renews_its_announce(MOQT_22).await } +#[moq_clock::test] +async fn moqt_16_route_auth_survives_a_reconnect() { + a_route_auth_survives_a_reconnect("moq-transport-16").await +} + +#[moq_clock::test] +async fn moqt_22_route_auth_survives_a_reconnect() { + a_route_auth_survives_a_reconnect(MOQT_22).await +} + #[moq_clock::test] 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, answer) = pair.client.auth().add("first").await.expect("kept for requests"); + assert_eq!(answer, auth::Answer::Requests); let broadcast = client_origin.create_broadcast("room/x").unwrap(); broadcast.announce(origin::Route::default().with_auth(first)).unwrap(); let (_, first) = tokens.next().await.expect("the announce's token"); @@ -159,7 +179,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"); moq_clock::sleep(Duration::from_secs(1)).await; - let second = pair.client.auth().add("second").await.expect("kept for requests"); + let (second, _) = pair.client.auth().add("second").await.expect("kept for requests"); broadcast.announce(origin::Route::default().with_auth(second)).unwrap(); let (token, _second) = tokens.next().await.expect("announced again"); assert_eq!(token, b"second"); @@ -178,12 +198,12 @@ 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, _) = pair.client.auth().add("first").await.expect("kept for requests"); let broadcast = client_origin.create_broadcast("room/x").unwrap(); broadcast.announce(origin::Route::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, _) = pair.client.auth().add("second").await.expect("kept for requests"); broadcast.announce(origin::Route::default().with_auth(second)).unwrap(); moq_clock::sleep(Duration::from_secs(1)).await; drop(first); @@ -202,7 +222,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, _) = pair.client.auth().add("first").await.expect("kept for requests"); let broadcast = client_origin.create_broadcast("room/x").unwrap(); broadcast.announce(origin::Route::default().with_auth(first)).unwrap(); let first = requests @@ -212,7 +232,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, _) = pair.client.auth().add("second").await.expect("kept for requests"); broadcast.announce(origin::Route::default().with_auth(second)).unwrap(); let renewal = requests.next().await.expect("the renewal's token"); assert_eq!(renewal.token().as_ref(), b"second"); @@ -255,7 +275,7 @@ async fn moqt_22_a_revoked_route_auth_remakes_its_announce() { let (token, _setup) = tokens.next().await.expect("the setup token"); assert!(token.is_empty()); - let first = pair.client.auth().add("first").await.expect("granted"); + let (first, _) = pair.client.auth().add("first").await.expect("granted"); let (token, presented) = tokens.next().await.expect("the AUTH stream's token"); assert_eq!(token, b"first"); let broadcast = client_origin.create_broadcast("room/x").unwrap(); @@ -277,24 +297,63 @@ async fn moqt_22_a_revoked_route_auth_remakes_its_announce() { /// request, and the server declines MoQ Solicit, so the client announces with /// PUBLISH_NAMESPACE rather than answering a SUBSCRIBE_NAMESPACE. async fn connect_route_auth(version: &'static str) -> (Pair, origin::Producer, origin::Producer) { + let client_origin = produce_origin(2); + let server_origin = produce_origin(1); + let pair = connect_route_auth_on(version, &client_origin, &server_origin, None).await; + (pair, client_origin, server_origin) +} + +/// [`connect_route_auth`] between existing origins, optionally on a shared client handle. +async fn connect_route_auth_on( + version: &'static str, + client_origin: &origin::Producer, + server_origin: &origin::Producer, + client_auth: Option, +) -> Pair { let declined = |f: fn(&mut moq_net::setup::Extensions)| { let mut extensions = moq_net::setup::Extensions::default(); f(&mut extensions); Some(extensions) }; - let client_origin = produce_origin(2); - let server_origin = produce_origin(1); - let pair = connect(Options { + connect(Options { version: Some(version), client_publish: Some(client_origin.clone()), server_subscribe: Some(server_origin.clone()), server_requests: true, client_extensions: declined(|e| e.auth = false), server_extensions: declined(|e| e.solicit = false), + client_auth, ..Default::default() }) - .await; - (pair, client_origin, server_origin) + .await +} + +/// A route naming a shared handle's auth carries it on the next session too, with no +/// re-add or re-announce from the application. +async fn a_route_auth_survives_a_reconnect(version: &'static str) { + within(async { + let handle = auth::Handle::new(); + let client_origin = produce_origin(2); + 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 broadcast = client_origin.create_broadcast("room/x").unwrap(); + broadcast.announce(origin::Route::default().with_auth(token)).unwrap(); + let (token, _first) = tokens.next().await.expect("the announce's token"); + assert_eq!(token, b"first"); + + first.server.abort(Error::Cancel); + first.client.closed().await; + + let mut second = connect_route_auth_on(version, &client_origin, &server_origin, Some(handle)).await; + let mut tokens = serve(second.requests.take().unwrap(), |_| Some(Grant::all())); + let (token, _second) = tokens.next().await.expect("the announce's token on the new session"); + assert_eq!(token, b"first"); + wait_announced(&server_origin.consume(), "room/x", true).await; + }) + .await + .expect("timed out"); } /// A route's auth reaches the peer's acceptor on the announcement, and swapping it @@ -304,14 +363,14 @@ 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, _) = pair.client.auth().add("first").await.expect("kept for requests"); let broadcast = client_origin.create_broadcast("room/x").unwrap(); broadcast.announce(origin::Route::default().with_auth(first)).unwrap(); let (token, _first) = tokens.next().await.expect("the announce's token"); 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, _) = pair.client.auth().add("second").await.expect("kept for requests"); broadcast.announce(origin::Route::default().with_auth(second)).unwrap(); let (token, _second) = tokens.next().await.expect("the renewal's token"); assert_eq!(token, b"second"); @@ -399,6 +458,8 @@ struct Options { client_extensions: Option, server_extensions: Option, version: Option<&'static str>, + /// The handle the client builds its session on, when not its own. + client_auth: Option, } struct Pair { @@ -427,6 +488,9 @@ async fn connect(opts: Options) -> Pair { if let Some(subscribe) = opts.client_subscribe { client = client.with_subscriber(subscribe); } + if let Some(handle) = opts.client_auth { + client = client.with_auth(handle); + } let mut server = Server::new().with_versions(version.into()); if let Some(extensions) = opts.server_extensions { @@ -707,7 +771,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, _) = pair.client.auth().add("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"], &[])); @@ -749,7 +813,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, _) = pair.client.auth().add("t1").await.unwrap(); let (_, t1_issued) = issued.next().await.unwrap(); t1_issued.update(grant(&["c"], &["c"])); @@ -792,7 +856,7 @@ async fn a_revoked_grant_withdraws_and_can_be_restored(version: &'static str) { assert!(publisher.consume().routed("a/x").await.is_some()); assert_eq!(pair.client_transport.close_reason(), None); - let _again = pair.client.auth().add("again").await.expect("re-authorized"); + let (_again, _) = pair.client.auth().add("again").await.expect("re-authorized"); wait_announced(&served, "a/x", true).await; }) .await @@ -884,7 +948,7 @@ async fn dropping_the_requests_refuses_queued_tokens(version: &'static str) { .expect("timed out"); } -/// A closed session holds no grant: every token ended with it. +/// A closed session holds no grant: no session is live, and every token ended with it. async fn a_closed_session_holds_no_grant(version: &'static str) { within(async { let pair = connect(Options { @@ -898,7 +962,7 @@ async fn a_closed_session_holds_no_grant(version: &'static str) { let token = pair.client.auth().grant(); pair.server.abort(Error::Cancel); - wait_for(token, |g| g == &Some(Grant::default())).await; + wait_for(token, Option::is_none).await; }) .await .expect("timed out"); @@ -959,7 +1023,7 @@ async fn older_versions_have_no_grant(version: &'static str) { match version.starts_with("moq-lite") { true => assert!(matches!(pair.client.auth().add("token").await, Err(Error::Unsupported))), false => { - let auth = pair.client.auth().add("token").await.expect("kept for requests"); + let (auth, _) = pair.client.auth().add("token").await.expect("kept for requests"); assert_eq!(auth.grant().peek(), None); assert_eq!(pair.client.auth().grant().peek(), None); } @@ -1232,7 +1296,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, _) = pair.client.auth().add("update").await.unwrap(); let (_, updated) = issued.next().await.unwrap(); assert_eq!(granted(&pair.client).await, grant(&["a", "b"], &[])); let huge: Patterns = (0..20) @@ -1290,7 +1354,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, _) = pair.client.auth().add(token).await.expect(token); assert_eq!(added.grant().peek(), table(token.as_bytes()), "{token}"); held.push(added); } @@ -1768,3 +1832,220 @@ async fn a_widening_brings_back_what_the_peer_published(version: &'static str) { .await .expect("timed out"); } + +/// A handle passed to the client presents its token on every session it backs: added before +/// any session, granted on the first, cleared while disconnected, and granted again on the +/// next, without the token ending in between. +async fn a_shared_handle_presents_its_token_on_every_session(version: &'static str) { + within(async { + let handle = auth::Handle::new(); + let added = moq_clock::sim::spawn({ + let handle = handle.clone(); + async move { handle.add("token").await } + }); + let options = || Options { + version: Some(version), + client_publish: Some(produce_origin(2)), + server_requests: true, + client_auth: Some(handle.clone()), + ..Default::default() + }; + + let mut first = connect(options()).await; + let mut tokens = serve(first.requests.take().unwrap(), |token| match token { + b"" => Some(Grant::default()), + _ => Some(grant(&["first"], &[])), + }); + let (token, answer) = added.await.expect("joined").expect("granted on the first session"); + assert_eq!(answer, auth::Answer::Granted); + let _first = presented(&mut tokens).await; + wait_for(token.grant(), |g| g == &Some(grant(&["first"], &[]))).await; + + first.server.abort(Error::Cancel); + first.client.closed().await; + assert_eq!(token.grant().peek(), None, "no session is live"); + assert!( + futures::FutureExt::now_or_never(token.closed()).is_none(), + "a session ending is no verdict" + ); + + let mut second = connect(options()).await; + let mut tokens = serve(second.requests.take().unwrap(), |token| match token { + b"" => Some(Grant::default()), + _ => Some(grant(&["second"], &[])), + }); + let _second = presented(&mut tokens).await; + wait_for(token.grant(), |g| g == &Some(grant(&["second"], &[]))).await; + wait_for(handle.grant(), |g| g == &Some(grant(&["second"], &[]))).await; + }) + .await + .expect("timed out"); +} + +/// The setup token and the handle's token, in either order, once both are presented. +async fn presented( + tokens: &mut futures::channel::mpsc::UnboundedReceiver<(Vec, auth::Issued)>, +) -> Vec<(Vec, auth::Issued)> { + let mut both = vec![ + tokens.next().await.expect("a token"), + tokens.next().await.expect("another token"), + ]; + both.sort_by(|a, b| a.0.cmp(&b.0)); + assert_eq!(both[0].0, b""); + assert_eq!(both[1].0, b"token"); + both +} + +/// A handle backs one active session: another fails loudly until the first drains. The +/// draining session keeps presenting the handle's tokens beside its successor, even one +/// its peer has not answered yet, since its requests may still rely on them. A token added +/// after the drain rides only the successor: a draining session opens no new streams. +async fn a_handle_backs_one_active_session(version: &'static str) { + within(async { + use futures::FutureExt; + + let handle = auth::Handle::new(); + let options = || Options { + version: Some(version), + client_publish: Some(produce_origin(2)), + server_requests: true, + client_auth: Some(handle.clone()), + ..Default::default() + }; + let mut first = connect(options()).await; + let mut first_requests = first.requests.take().unwrap(); + let added = moq_clock::sim::spawn({ + let handle = handle.clone(); + async move { handle.add("token").await } + }); + let mut first_issued = Vec::new(); + let unanswered = loop { + let request = first_requests.next().await.expect("a token"); + if request.token().is_empty() { + first_issued.push(request.accept(Grant::default())); + continue; + } + assert_eq!(request.token().as_ref(), b"token"); + break request; + }; + + let parsed: Version = version.parse().unwrap(); + let mut mock = moq_net::transport::memory::Config::default(); + mock.protocol = Some(parsed.alpn().to_string()); + let (transport, _peer) = create_mock_session_pair(mock); + let concurrent = Client::new() + .with_versions(parsed.into()) + .with_auth(handle.clone()) + .connect(now(), transport) + .await; + assert!(matches!(concurrent, Err(Error::Duplicate)), "{:?}", concurrent.err()); + + first.server.drain().send(moq_net::goaway::Goaway::new()).unwrap(); + first.client.draining().recv().await.expect("the GOAWAY"); + 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"); + + moq_clock::sleep(Duration::from_secs(1)).await; + assert!( + unanswered.closed().now_or_never().is_none(), + "the draining session withdrew its unanswered token" + ); + let first_grant = unanswered.accept(Grant::all()); + moq_clock::sleep(Duration::from_secs(1)).await; + assert!( + first_grant.closed().now_or_never().is_none(), + "the draining session dropped its late grant" + ); + + let (later, _) = handle.add("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!( + first_requests.next().now_or_never().is_none(), + "the draining session opened a new stream" + ); + drop(later); + }) + .await + .expect("timed out"); +} + +/// A token queued while the session is active, but dequeued only after this side sent a +/// GOAWAY, opens no request on the draining session: its successor presents it. +async fn a_token_queued_before_our_goaway_rides_only_the_successor(version: &'static str) { + within(async { + use futures::FutureExt; + + let handle = auth::Handle::new(); + let options = || Options { + version: Some(version), + client_publish: Some(produce_origin(2)), + server_requests: true, + client_auth: Some(handle.clone()), + ..Default::default() + }; + let mut first = connect(options()).await; + let mut first_tokens = serve(first.requests.take().unwrap(), |_| Some(Grant::all())); + let (setup, _setup) = first_tokens.next().await.expect("the setup token"); + 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")); + assert!(adding.as_mut().now_or_never().is_none()); + first.client.drain().send(moq_net::goaway::Goaway::new()).unwrap(); + moq_clock::sleep(Duration::from_secs(1)).await; + assert!( + first_tokens.next().now_or_never().is_none(), + "the draining session presented a new token" + ); + + let mut second = connect(options()).await; + let mut second_tokens = serve(second.requests.take().unwrap(), |_| Some(Grant::all())); + let mut tokens = vec![ + second_tokens.next().await.expect("a token").0, + second_tokens.next().await.expect("another token").0, + ]; + tokens.sort(); + assert_eq!(tokens, [b"".to_vec(), b"late".to_vec()]); + let (_late, answer) = adding.await.expect("granted on the successor"); + assert_eq!(answer, auth::Answer::Granted); + }) + .await + .expect("timed out"); +} + +/// A refusal that may pass, such as an overloaded verifier, presents the token again +/// after a backoff rather than ending it. +async fn a_transient_refusal_is_presented_again(version: &'static str) { + within(async { + let mut pair = connect(Options { + version: Some(version), + client_publish: Some(produce_origin(2)), + server_requests: true, + ..Default::default() + }) + .await; + let mut requests = pair.requests.take().unwrap(); + let (tx, mut issued) = futures::channel::mpsc::unbounded(); + moq_clock::sim::spawn(async move { + let mut refused = false; + while let Some(request) = requests.next().await { + if request.token().is_empty() || refused { + let _ = tx.unbounded_send(request.accept(Grant::default())); + } else { + refused = true; + request.reject(SessionError::TooManyRequests, "busy"); + } + } + }); + let (token, answer) = pair.client.auth().add("token").await.expect("granted on the retry"); + assert_eq!(answer, auth::Answer::Granted); + assert_eq!(token.grant().peek(), Some(Grant::default())); + let _setup = issued.next().await; + let _token = issued.next().await; + }) + .await + .expect("timed out"); +} diff --git a/rs/moq-net/tests/integration/extensions.rs b/rs/moq-net/tests/integration/extensions.rs index d9b5ac39370..7e8364c60db 100644 --- a/rs/moq-net/tests/integration/extensions.rs +++ b/rs/moq-net/tests/integration/extensions.rs @@ -139,10 +139,11 @@ async fn a_declined_auth_extension_is_not_negotiated() { assert_eq!(session.auth().grant().peek(), None, "{context}"); // Only a request that names it can carry the token, so it resolves at once with // a grant that stays unknown. - let added = moq_clock::timeout(SETTLE, session.auth().add("token")) + let (added, answer) = moq_clock::timeout(SETTLE, session.auth().add("token")) .await .unwrap_or_else(|_| panic!("{context}: add never resolved")) .unwrap_or_else(|err| panic!("{context}: {err:?}")); + assert_eq!(answer, moq_net::auth::Answer::Requests, "{context}"); assert_eq!(added.grant().peek(), None, "{context}"); assert_eq!(session.auth().grant().peek(), None, "{context}"); } diff --git a/rs/moq-relay/benches/auth.rs b/rs/moq-relay/benches/auth.rs index a470739d7bb..300bedb67c8 100644 --- a/rs/moq-relay/benches/auth.rs +++ b/rs/moq-relay/benches/auth.rs @@ -16,10 +16,10 @@ fn grant(prefix: &str) -> Grant { struct Fixture { _connections: Vec, - _tokens: Vec, + _tokens: Vec, producers: Vec, selected: usize, - watch: moq_tokio::connection::auth::Watch, + watch: moq_net::auth::Watch, tasks: Vec>, alternate: bool, } @@ -83,7 +83,7 @@ impl Fixture { 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: Vec<_> = added.into_iter().map(Result::unwrap).collect(); + let added: Vec<_> = added.into_iter().map(|added| added.unwrap().0).collect(); if session == 0 { let mut observed = added.last().unwrap().grant(); observed.changed().await.unwrap(); @@ -148,7 +148,7 @@ fn refresh(c: &mut Criterion) { /// so requests beyond the fixed production window wait for a verification slot. struct Queue { _connection: moq_tokio::Connection, - auth: moq_tokio::connection::auth::Handle, + auth: moq_net::auth::Handle, admissions: auth::Admissions, presented: usize, tasks: Vec>, diff --git a/rs/moq-relay/src/auth/refresh.rs b/rs/moq-relay/src/auth/refresh.rs index 231ac4daa4a..61ebe01a16f 100644 --- a/rs/moq-relay/src/auth/refresh.rs +++ b/rs/moq-relay/src/auth/refresh.rs @@ -640,23 +640,30 @@ mod tests { async fn an_invalid_attachment_is_refused_without_losing_the_existing_grant() { 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("invalid"), async { - let mut invalid = grant("a/**", 3000); - invalid.label = Some("project/*".into()); - admissions.next().await.unwrap().grant(lease::Consumer::fixed(invalid)); - }); - assert!(result.is_err(), "the invalid label was discarded"); + let invalid = present(&client, "invalid"); + let mut label = grant("a/**", 3000); + label.label = Some("project/*".into()); + admissions.next().await.unwrap().grant(lease::Consumer::fixed(label)); + // Refused as an auth server fault, which may pass, so the client presents it again. + let retried = admissions.next().await.unwrap(); + assert_eq!(retried.request.query.as_deref(), Some("jwt=invalid")); + assert!( + !invalid.is_finished(), + "the invalid label was accepted or ended the token" + ); assert!( !supervising.is_finished(), "a refused attachment ended the initial grant" ); + // Withdrawn, the retried token leaves the queue. + invalid.abort(); + let _ = invalid.await; + drop(retried); + let auth = client.auth(); let (result, ()) = tokio::join!(auth.add("valid"), async { - admissions - .next() - .await - .unwrap() - .grant(lease::Consumer::fixed(grant("a/**", 3000))); + let admission = admissions.next().await.unwrap(); + assert_eq!(admission.request.query.as_deref(), Some("jwt=valid")); + admission.grant(lease::Consumer::fixed(grant("a/**", 3000))); }); result.unwrap(); client.abort(moq_net::Error::Cancel); @@ -753,16 +760,21 @@ mod tests { let clock = manual(); let (lease, mut admissions) = admission(grant("a/**", 30)).await; let (client, mut supervising) = sessions(lease).await; - for _ in 0..3 { - let auth = client.auth(); - let (result, ()) = tokio::join!(auth.add("replacement"), async { - admissions - .next() - .await - .unwrap() - .refuse(super::super::Error::Unavailable("auth server down".into())); - }); - assert!(matches!(result, Err(moq_net::Error::Session(SessionError::Internal)))); + let mut adding = present(&client, "replacement"); + for attempt in 0..3 { + let admission = admissions.next().await.unwrap(); + assert_eq!( + admission.request.query.as_deref(), + Some("jwt=replacement"), + "attempt {attempt}" + ); + admission.refuse(super::super::Error::Unavailable("auth server down".into())); + // The refusal may pass, so the client presents the token again after a backoff + // shorter than each step below, rather than ending it. + assert!( + settles_pending(&mut adding).await, + "a transient refusal ended the token" + ); clock.advance(Duration::from_secs(5)); } clock.advance(Duration::from_secs(15) - Duration::from_nanos(1)); @@ -776,6 +788,7 @@ mod tests { // instant, and the client's transport can break before the close code lands. client.closed().await; client.abort(moq_net::Error::Cancel); + adding.abort(); } /// Present `token` in the background, as a peer whose credit allows it. @@ -819,7 +832,8 @@ mod tests { } /// A waiting token whose admission times out is refused without reaching the - /// decider, while the check it waited behind keeps its slot past the timeout. + /// decider, while the check it waited behind keeps its slot past the timeout. The + /// refusal may pass, so the client keeps presenting both tokens. #[tokio::test(start_paused = true)] async fn a_waiting_token_times_out_without_reaching_the_decider() { let (lease, mut admissions) = admission_with(grant("a/**", 3000), 1).await; @@ -827,20 +841,23 @@ mod tests { let slow = present(&client, "slow"); let held = admissions.next().await.unwrap(); let waiting = present(&client, "waiting"); - assert!(matches!( - waiting.await.unwrap(), - Err(moq_net::Error::Session(SessionError::Internal)) - )); - assert!(matches!( - slow.await.unwrap(), - Err(moq_net::Error::Session(SessionError::Internal)) - )); + // Past the ten-second admission timeout, and the retries that follow it. + tokio::time::sleep(Duration::from_secs(30)).await; + assert!( + !waiting.is_finished() && !slow.is_finished(), + "a transient refusal ended a token" + ); assert!( admissions.next().now_or_never().is_none(), "a timed-out check released its slot" ); - // The decider giving up frees the slot for the next token, not the expired one. + // Withdrawn, the retried tokens leave the queue. The decider giving up frees the + // slot for the next token, not the expired one. + slow.abort(); + waiting.abort(); + let _ = slow.await; + let _ = waiting.await; drop(held); let _next = present(&client, "next"); let admission = admissions.next().await.unwrap(); @@ -861,7 +878,7 @@ mod tests { assert_eq!(admission.request.query.as_deref(), Some("jwt=replacement")); admission.grant(lease::Consumer::fixed(grant("a/**", 20))); }); - let token = token.unwrap(); + let (token, _) = token.unwrap(); clock.advance(Duration::from_secs(4)); assert!( settles_pending(&mut supervising).await, @@ -911,7 +928,7 @@ mod tests { let (token, ()) = tokio::join!(auth.add("replacement"), async { admissions.next().await.unwrap().grant(consumer); }); - let token = token.unwrap(); + let (token, _) = token.unwrap(); assert!( token.grant().peek().unwrap().subscribe.is_empty(), "publisher SETUP role widened" @@ -960,7 +977,7 @@ mod tests { assert!( matches!(result, Err(moq_net::Error::Session(SessionError::Unauthorized))), "an empty token reissued a credential the relay removed: {:?}", - result.map(|token| token.grant().peek()) + result.map(|(token, _)| token.grant().peek()) ); assert!(!supervising.is_finished()); client.abort(moq_net::Error::Cancel); diff --git a/rs/moq-relay/tests/integration/runtime_uring.rs b/rs/moq-relay/tests/integration/runtime_uring.rs index 98081f150a8..9ee77a18f77 100644 --- a/rs/moq-relay/tests/integration/runtime_uring.rs +++ b/rs/moq-relay/tests/integration/runtime_uring.rs @@ -1006,16 +1006,18 @@ async fn uring_workers_offer_the_configured_extensions() { assert!(admission.attached(), "an AUTH token arrived as a new session"); admission.grant(grant()); }); - token + let (_, answer) = token .expect("the token was never answered") .expect("the token was refused"); + assert_eq!(answer, moq_net::auth::Answer::Granted); } false => { - let err = presented.await.expect("the token was never answered").err(); - assert!( - matches!(err, Some(moq_tokio::Error::MoqNet(moq_net::Error::Unsupported))), - "a token without MoQ Auth: {err:?}" - ); + // Without MoQ Auth no AUTH stream carries it: only a request naming it does. + let (_, answer) = presented + .await + .expect("the token was never answered") + .expect("a moq-transport request can carry the token"); + assert_eq!(answer, moq_net::auth::Answer::Requests, "a token without MoQ Auth"); } } diff --git a/rs/moq-tokio/src/client.rs b/rs/moq-tokio/src/client.rs index fa4c631f165..6d4e069eabb 100644 --- a/rs/moq-tokio/src/client.rs +++ b/rs/moq-tokio/src/client.rs @@ -41,7 +41,7 @@ impl Config { /// Create via [`crate::connect::Config::init`] or [`Client::new`]. #[derive(Clone)] pub struct Client { - moq: moq_net::Client, + pub(crate) moq: moq_net::Client, /// The single resolved set of protocol versions, used to advertise moq ALPNs across /// every transport (passed into the QUIC backend's `connect` and used directly for /// raw TCP/UDS qmux and WebSocket). Resolved once in [`Client::new`] so the ALPN list diff --git a/rs/moq-tokio/src/connection.rs b/rs/moq-tokio/src/connection.rs index 6ceffff789c..a37be008977 100644 --- a/rs/moq-tokio/src/connection.rs +++ b/rs/moq-tokio/src/connection.rs @@ -1,7 +1,5 @@ //! The reconnect loop around a session, and the [`Monitor`] that observes it. -pub mod auth; - use std::task::{Poll, ready}; use std::time::Duration; @@ -266,7 +264,8 @@ pub enum Redirect { /// range, or the metadata service, and nothing here resolves it to find out. A /// *literal* loopback, private, link-local, or IPC target is still refused, but /// that catches a misconfigured peer, not a hostile one. Use it only with an - /// upstream trusted with the local network. + /// upstream trusted with the local network and with the tokens on + /// [`Connection::auth`], which every session presents, on any host. Follow, /// Follow only when the host matches the configured URL, so a peer can move us /// between ports or schemes but not to another host. The default. @@ -582,8 +581,6 @@ struct State { revision: u64, /// The loop has finished and cannot publish another state. ended: bool, - /// Tokens to queue before publishing a replacement session to its consumers. - tokens: std::sync::Mutex>>, /// Current connection status, or `None` before the first connect. status: Option, /// How many sessions have been live on this handle, counting the current one. @@ -622,6 +619,8 @@ struct Shared { send_bw: BandwidthProducer, recv_bw: BandwidthProducer, closed: CloseGuard, + /// The auth handle every session is built on, ended with the loop. + auth: moq_net::auth::Handle, } impl Shared { @@ -635,7 +634,6 @@ impl Shared { return; } if let Ok(mut state) = self.state.write() { - auth::replay(&state, session, state.connects + 1); // A migration replaces a live session without a disconnect in between, so // its end is counted here, keeping `started - ended` at 1 while connected. if state.session.is_some() { @@ -695,6 +693,15 @@ impl Shared { /// from. `err` is what it gave up with, for [`Connection::closed`]. Nothing is /// retrying anymore, so [`Monitor::failure`] stops reporting. fn end(&self, err: Option) { + // No session will present the tokens again, so they end with the loop. + let aborted = self.closed.lock().unwrap().clone(); + let auth_err = match (aborted, &err) { + (Some(err), _) => err, + (None, Some(Error::MoqNet(err))) => err.clone(), + (None, Some(err)) if err.is_auth() => moq_net::Error::Unauthorized, + (None, Some(err)) => moq_net::Error::Transport(err.to_string()), + (None, None) => moq_net::Error::Closed, + }; if let Ok(mut state) = self.state.write() { if state.ended { return; @@ -706,6 +713,11 @@ impl Shared { } let _ = self.send_bw.set(None); let _ = self.recv_bw.set(None); + self.auth.abort(match auth_err { + // A token reads `Cancel` as its peer ending the grant cleanly. + moq_net::Error::Cancel => moq_net::Error::Closed, + err => err, + }); } } @@ -907,8 +919,8 @@ pub struct Snapshot { pub struct Connection { task: std::sync::Arc, state: kio::Consumer, - /// The reconnect pacing, which retained tokens reuse to retry a transient refusal. - pacing: Pacing, + /// The auth handle every session of the loop is built on. + auth: moq_net::auth::Handle, /// Persistent send-bitrate estimate, fed by the loop from each live session. send_bandwidth: BandwidthConsumer, /// Divides `send_bandwidth`, and every session the loop dials probes for its reservations. @@ -938,10 +950,11 @@ struct Task { type CloseGuard = std::sync::Arc>>; impl Connection { - pub(crate) fn new(client: Client, addrs: Addrs) -> Self { + pub(crate) fn new(mut client: Client, addrs: Addrs) -> Self { let producer = kio::Producer::::default(); let state = producer.consume(); - let pacing = Pacing::new(&client.backoff); + let auth = moq_net::auth::Handle::new(); + client.moq = client.moq.with_auth(auth.clone()); // The loop feeds these across every reconnect, so a consumer's handle survives session churn // (unlike a session's own bandwidth consumer, which dies with the session). @@ -961,6 +974,7 @@ impl Connection { send_bw, recv_bw, closed: task_closed, + auth: auth.clone(), }; let task = tokio::spawn(async move { @@ -987,7 +1001,7 @@ impl Connection { closed, }), state, - pacing, + auth, send_bandwidth, recv_bandwidth, bandwidth, @@ -1082,6 +1096,11 @@ impl Connection { if !timeout.is_zero() && retry.start.elapsed() >= timeout { return Err(timeout_error(timeout, retry.last_error.as_ref())); } + // An ended auth handle refuses every later session, so no dial can recover. A fresh + // watch only reports an error once its handle has ended. + if let Poll::Ready(Err(err)) = shared.auth.grant().poll_changed(&kio::Waiter::noop()) { + return Err(err.into()); + } match Self::dial_any(shared, &client, &addrs, &mut draining).await { Ok((addr, dialed)) => { @@ -1519,13 +1538,15 @@ impl Connection { kio::wait(|waiter| self.poll_closed(waiter)).await } - /// Present tokens that survive session replacement and observe the live grant union. - /// A route names a session's own `moq_net::auth::Auth` instead, re-added after a reconnect. - pub fn auth(&self) -> auth::Handle { - auth::Handle { - connection: self.state.clone(), - pacing: self.pacing, - } + /// The auth handle every session of this connection is built on: its tokens are + /// presented on each reconnect, so a route naming one keeps it, and its grant follows + /// the live session. It ends with the connection: the abort's or [`Error::MoqNet`]'s + /// error, [`moq_net::Error::Unauthorized`] for a rejection, [`moq_net::Error::Transport`] + /// for any other failure, or [`moq_net::Error::Closed`] for a stop. Never + /// [`moq_net::Error::Cancel`], which a token reads as its peer ending the grant cleanly. + /// Aborting it ends the connection at its next reconnect, with the abort's error. + pub fn auth(&self) -> moq_net::auth::Handle { + self.auth.clone() } /// Observe the connection's statistics and presence across reconnects. @@ -2617,6 +2638,7 @@ mod tests { send_bw: BandwidthProducer::new(), recv_bw: BandwidthProducer::new(), closed: CloseGuard::default(), + auth: moq_net::auth::Handle::new(), } } @@ -2868,6 +2890,99 @@ mod tests { assert!(monitor.failure().is_none(), "nothing is retrying"); } + /// The connection's auth handle presents its token on every session the loop makes, + /// without the application adding it again. + #[cfg(feature = "tcp")] + #[tokio::test] + async fn a_token_is_presented_on_every_session() { + const WAIT: Duration = Duration::from_secs(10); + let version: moq_net::Version = "moq-lite-07-wip".parse().unwrap(); + + let mut config = crate::listen::Config::default(); + config.tcp.bind = Some("127.0.0.1:0".parse().unwrap()); + config.version = vec![version]; + let mut listener = config.init(Default::default()).unwrap().listen().await.unwrap(); + let addr = listener.tcp_local_addr().unwrap(); + + let mut config = crate::connect::Config { + version: vec![version], + ..Default::default() + }; + config.backoff.initial = Some(MIN_BACKOFF); + config.backoff.max = Some(MIN_BACKOFF); + let connection = config + .init(Default::default()) + .unwrap() + .connect(url(&format!("tcp://{addr}/"))); + let auth = connection.auth(); + let added = tokio::spawn({ + let auth = auth.clone(); + async move { auth.add("token").await } + }); + + // Held past each session's abort: dropping a grant closes its stream cleanly, which + // is the peer's verdict ending the token. + let mut issued = Vec::new(); + let mut added = Some(added); + let mut token = None; + for round in 0..2 { + let mut request = tokio::time::timeout(WAIT, listener.accept()).await.unwrap().unwrap(); + let mut requests = request.requests(); + let (session, _task) = request.ok().await.unwrap(); + loop { + let request = tokio::time::timeout(WAIT, requests.next()).await.unwrap().unwrap(); + if request.token().is_empty() { + issued.push(request.accept(moq_net::auth::Grant::default())); + continue; + } + assert_eq!(request.token().as_ref(), b"token", "round {round}"); + issued.push(request.accept(moq_net::auth::Grant::all())); + break; + } + if let Some(added) = added.take() { + token = Some(added.await.unwrap().expect("granted").0); + } + let mut grant = token.as_ref().unwrap().grant(); + while grant.peek() != Some(moq_net::auth::Grant::all()) { + tokio::time::timeout(WAIT, grant.changed()).await.unwrap().unwrap(); + } + session.abort(moq_net::Error::Cancel); + } + drop(connection); + let token = token.unwrap(); + // Not `Cancel`, which reads as the peer ending the grant cleanly. + let closed = tokio::time::timeout(WAIT, token.closed()).await.unwrap(); + assert!(matches!(closed, moq_net::Error::Closed), "{closed:?}"); + } + + /// Ending the connection's auth handle ends the loop at its next reconnect, rather than + /// redialing sessions the handle refuses. + #[cfg(feature = "tcp")] + #[tokio::test] + async fn an_ended_auth_handle_ends_the_loop() { + const WAIT: Duration = Duration::from_secs(10); + + let (addr, mut accepted, _task) = serve(crate::origin::spawn()).await; + let mut config = crate::connect::Config::default(); + config.backoff.initial = Some(MIN_BACKOFF); + config.backoff.max = Some(MIN_BACKOFF); + let connection = config + .init(Default::default()) + .unwrap() + .connect(url(&format!("tcp://{addr}/"))); + + let session = tokio::time::timeout(WAIT, accepted.recv()).await.unwrap().unwrap(); + connection.auth().abort(moq_net::Error::App(9)); + session.abort(moq_net::Error::Cancel); + let closed = tokio::time::timeout(WAIT, connection.closed()) + .await + .expect("the loop kept redialing on an ended auth handle"); + assert!( + matches!(closed, Err(Error::MoqNet(moq_net::Error::App(9)))), + "{closed:?}" + ); + } + /// A session that ends the loop reports through `closed`, not as a retry reason. #[cfg(feature = "tcp")] #[tokio::test] diff --git a/rs/moq-tokio/src/connection/auth.rs b/rs/moq-tokio/src/connection/auth.rs deleted file mode 100644 index 06416321759..00000000000 --- a/rs/moq-tokio/src/connection/auth.rs +++ /dev/null @@ -1,732 +0,0 @@ -//! Tokens and grant watches that follow a reconnecting connection. - -use std::{ - sync::{Arc, Mutex}, - task::{Poll, ready}, -}; - -use bytes::Bytes; -use futures::FutureExt; -use moq_net::{auth, kio}; - -use super::{Pacing, State, terminal}; -use crate::{Error, abort::AbortOnDrop}; - -/// Present session tokens across every session of a connection. -#[derive(Clone)] -pub struct Handle { - pub(super) connection: kio::Consumer, - pub(super) pacing: Pacing, -} - -impl Handle { - /// Watch the current session's union, clearing it while disconnected. - pub fn grant(&self) -> Watch { - Watch { - source: Source::Connection(self.connection.clone()), - last: None, - } - } - - /// Present a token now and on later sessions until its handle is dropped or refused. - /// - /// A session that cannot send it, without AUTH, refuses it as unsupported. - pub async fn add(&self, token: impl Into) -> crate::Result { - let producer = kio::Producer::::default(); - let state = producer.consume(); - let connection = self.connection.clone(); - let pacing = self.pacing; - let token = token.into(); - let replay = Arc::new(Replay { - bytes: token, - queued: Mutex::new(Vec::new()), - }); - { - let state = connection.read(); - let mut tokens = state.tokens.lock().unwrap(); - tokens.retain(|token| token.strong_count() > 0); - if let Some(session) = &state.session { - replay.queue(session, state.connects); - } - tokens.push(Arc::downgrade(&replay)); - } - let (answered, answer) = tokio::sync::oneshot::channel(); - let task = AbortOnDrop::new(tokio::spawn(async move { - let mut answered = Some(answered); - if let Err(err) = present(connection, pacing, replay, &producer, &mut answered).await { - if let Some(answered) = answered.take() { - let _ = answered.send(Err(err.clone())); - } - if let Ok(mut state) = producer.write() { - state.grant = None; - state.error = Some(err); - } - } - })); - answer.await.map_err(|_| Error::from(moq_net::Error::Cancel))??; - Ok(Auth { _task: task, state }) - } -} - -type Opening = futures::future::BoxFuture<'static, moq_net::Result>; - -/// Registration shared with session publication, while the task owns its token's life. -pub(super) struct Replay { - bytes: Bytes, - /// Registrations queued as sessions are published, until the task takes them. - queued: Mutex>, -} - -struct Queued { - connects: u64, - session: moq_net::Session, - opening: Opening, -} - -impl Replay { - fn queue(&self, session: &moq_net::Session, connects: u64) { - let handle = session.auth(); - let bytes = self.bytes.clone(); - let mut opening = async move { handle.add(bytes).await }.boxed(); - // The first poll registers the token synchronously, before the session can be - // observed. The replay task replaces this noop waker when it takes the future. - if let Some(result) = opening.as_mut().now_or_never() { - opening = futures::future::ready(result).boxed(); - } - self.queued.lock().unwrap().push(Queued { - connects, - session: session.clone(), - opening, - }); - } - - /// Take the registration queued for session `connects`, moving older ones to `draining`. - fn take(&self, connects: u64, draining: &mut Draining) -> Option { - let mut queued = self.queued.lock().unwrap(); - let mut current = None; - for entry in std::mem::take(&mut *queued) { - match entry.connects.cmp(&connects) { - // Requests on a draining session may still be waiting on this answer. - std::cmp::Ordering::Less => draining.push(entry.session, entry.opening), - std::cmp::Ordering::Equal => current = Some(entry.opening), - // A replacement published since the task's snapshot already holds this token. - // Dropping its registration would withdraw the token from a session consumers - // can see, so leave it for the task's next pass. - std::cmp::Ordering::Greater => queued.push(entry), - } - } - current - } -} - -/// Tokens kept on sessions draining after a migration. Each is held by a task that -/// releases it, and its session, once that session ends; dropping this withdraws them all. -#[derive(Default)] -struct Draining(Vec); - -impl Draining { - /// Keep `held`, a token answered or not, registered on `session` until it ends. - fn push(&mut self, session: moq_net::Session, held: impl Send + 'static) { - self.0.retain(|task| !task.is_finished()); - self.0.push(AbortOnDrop::new(tokio::spawn(async move { - session.closed().await; - drop(held); - }))); - } -} - -pub(super) fn replay(state: &State, session: &moq_net::Session, connects: u64) { - state.tokens.lock().unwrap().retain(|token| { - let Some(token) = token.upgrade() else { - return false; - }; - token.queue(session, connects); - true - }); -} - -/// A presented token; dropping it withdraws it from live and draining sessions. -pub struct Auth { - _task: AbortOnDrop, - state: kio::Consumer, -} - -impl Auth { - /// Watch this token's grant on the current session. - pub fn grant(&self) -> Watch { - Watch { - source: Source::Auth(self.state.clone()), - last: None, - } - } - - /// Wait until the token ends: `Ok` when the peer closed it without an error, otherwise its - /// refusal or revocation, or the connection's end. - pub async fn closed(&self) -> crate::Result<()> { - kio::wait(|waiter| self.state.poll_closed(waiter)).await; - match &self.state.read().error { - Some(err) => Err(err.clone()), - None => Ok(()), - } - } -} - -#[derive(Default)] -struct Granted { - grant: Option, - error: Option, -} - -#[derive(Clone)] -enum Source { - Connection(kio::Consumer), - Auth(kio::Consumer), -} - -/// Observe a grant as tokens change and the connection replaces its session. -#[derive(Clone)] -pub struct Watch { - source: Source, - last: Option, -} - -impl Watch { - /// The current grant, or none while disconnected or after the token ends. - pub fn peek(&self) -> Option { - match &self.source { - Source::Connection(connection) => { - if connection.poll_closed(&kio::Waiter::noop()).is_ready() { - return None; - } - connection - .read() - .session - .as_ref() - .and_then(|session| session.auth().grant().peek()) - } - Source::Auth(state) => { - if state.poll_closed(&kio::Waiter::noop()).is_ready() { - return None; - } - state.read().grant.clone() - } - } - } - - /// Poll for a different grant or the end of its owner. - pub fn poll_changed(&mut self, waiter: &kio::Waiter) -> Poll>> { - let result = match &self.source { - Source::Connection(connection) => match ready!(connection.poll(waiter, |state| { - let mut watch = state.session.as_ref().map(|session| session.auth().grant()); - let current = watch.as_ref().and_then(auth::Watch::peek); - if current != self.last { - return Poll::Ready(current); - } - if let Some(watch) = &mut watch - && let Poll::Ready(Ok(grant)) = watch.poll_changed(waiter) - { - return Poll::Ready(grant); - } - Poll::Pending - })) { - Ok(grant) => Ok(grant), - Err(state) => Err(terminal(&state)), - }, - Source::Auth(state) => match ready!(state.poll(waiter, |state| { - if state.grant != self.last { - Poll::Ready(state.grant.clone()) - } else { - Poll::Pending - } - })) { - Ok(grant) => Ok(grant), - Err(state) => Err(state.error.clone().unwrap_or_else(|| moq_net::Error::Cancel.into())), - }, - }; - if let Ok(grant) = &result { - self.last = grant.clone(); - } - Poll::Ready(result) - } - - /// Wait for a different grant or the end of its owner. - pub async fn changed(&mut self) -> crate::Result> { - kio::wait(|waiter| self.poll_changed(waiter)).await - } -} - -async fn next( - connection: &kio::Consumer, - previous: Option<(u64, bool)>, -) -> crate::Result<(u64, Option)> { - kio::wait(|waiter| { - match ready!(connection.poll(waiter, |state| { - if Some((state.connects, state.session.is_some())) != previous { - Poll::Ready((state.connects, state.session.clone())) - } else { - Poll::Pending - } - })) { - Ok(session) => Poll::Ready(Ok(session)), - Err(state) => Poll::Ready(Err(terminal(&state))), - } - }) - .await -} - -/// Whether a refusal is the acceptor failing to decide rather than its verdict on -/// the token, such as an unreachable auth server or a full verification queue. -/// moq-transport's AUTH_ERROR carries neither, so there every refusal is final. -fn transient(err: &moq_net::Error) -> bool { - matches!( - err, - moq_net::Error::Session( - moq_net::SessionError::Internal | moq_net::SessionError::TooManyRequests | moq_net::SessionError::Timeout - ) - ) -} - -async fn present( - connection: kio::Consumer, - pacing: Pacing, - replay: Arc, - producer: &kio::Producer, - answered: &mut Option>>, -) -> crate::Result<()> { - let mut snapshot = next(&connection, None).await?; - let mut draining = Draining::default(); - 'session: loop { - let (connects, session) = snapshot; - let Some(session) = session else { - if let Ok(mut state) = producer.write() { - state.grant = None; - } - snapshot = next(&connection, Some((connects, false))).await?; - continue; - }; - if let Ok(mut state) = producer.write() { - state.grant = None; - } - let mut delay = None; - let mut opening = replay.take(connects, &mut draining); - let token = loop { - let attempt = async { - if opening.is_none() { - if let Some(delay) = delay { - moq_clock::sleep(delay).await; - } - let handle = session.auth(); - let bytes = replay.bytes.clone(); - opening = Some(async move { handle.add(bytes).await }.boxed()); - } - opening.as_mut().expect("opening").await - }; - let result = tokio::select! { - biased; - changed = next(&connection, Some((connects, true))) => Err(changed), - result = attempt => Ok(result), - }; - let result = match result { - Ok(result) => result, - Err(changed) => { - // Requests on a draining session may still be waiting on this answer. - if let Some(opening) = opening.take() { - draining.push(session, opening); - } - snapshot = changed?; - continue 'session; - } - }; - opening = None; - match result { - Ok(token) => break token, - // The session ending is no verdict on the token: replay it on the next one. - Err(_) if session.closed().now_or_never().is_some() => { - snapshot = next(&connection, Some((connects, true))).await?; - continue 'session; - } - // Nor is an acceptor that could not decide: present it again, paced like a - // reconnect. Only an accepted grant counts, so a retry never extends one. - Err(err) if transient(&err) => { - let wait = delay.map_or(pacing.initial, |delay| pacing.next(delay)); - tracing::debug!(%err, ?wait, "token refused transiently; presenting it again"); - delay = Some(wait); - } - Err(err) => return Err(err.into()), - } - }; - let mut watch = token.grant(); - // Without AUTH, moq-transport accepts a token at once for the requests that name it, - // but a connection's token rides no request, so nothing would send it. One the peer - // already ended has a grant no longer and is left to the watch below. - if watch.peek().is_none() && token.closed().now_or_never().is_none() { - return Err(moq_net::Error::Unsupported.into()); - } - if let Ok(mut state) = producer.write() { - state.grant = watch.peek(); - } - // After the grant, so `add` never returns a token that still reads none. - if let Some(answered) = answered.take() { - let _ = answered.send(Ok(())); - } - loop { - tokio::select! { - biased; - changed = next(&connection, Some((connects, true))) => { snapshot = changed?; break; } - _ = session.closed() => { - if let Ok(mut state) = producer.write() { state.grant = None; } - snapshot = next(&connection, Some((connects, true))).await?; - break; - } - grant = watch.changed() => match grant { - Ok(grant) => { - if let Ok(mut state) = producer.write() { state.grant = grant; } - } - // The stream ended with its session, not by the peer's verdict. - Err(_) if session.closed().now_or_never().is_some() => { - if let Ok(mut state) = producer.write() { state.grant = None; } - snapshot = next(&connection, Some((connects, true))).await?; - break; - } - // The peer closed it without an error: the token is over, not refused. - Err(moq_net::Error::Cancel) => return Ok(()), - Err(err) => return Err(err.into()), - } - } - } - // A migrated session may still be serving its streams during GOAWAY drain. - // Keep its grant until that session ends or the application drops this token. - draining.push(session, token); - } -} - -#[cfg(all(test, feature = "tcp"))] -#[expect( - clippy::disallowed_methods, - clippy::disallowed_types, - reason = "tests read the host clock directly" -)] -mod tests { - use super::*; - - struct Pair { - client: moq_net::Session, - requests: tokio::sync::mpsc::UnboundedReceiver, - _tasks: Vec>, - _acceptor: AbortOnDrop, - } - - async fn pair() -> Pair { - let versions = moq_net::Versions::from("moq-lite-07-wip".parse::().unwrap()); - let config = || { - let mut config = qmux::Config::new(qmux::Version::QMux01); - config.protocol = qmux::Protocol::Negotiate(versions.alpns().iter().map(|alpn| alpn.to_string()).collect()); - config - }; - let stream = |io| qmux::transport::Stream::new(io); - let (client, server) = tokio::io::duplex(64 * 1024); - let (client, server) = tokio::try_join!( - qmux::Session::connect(stream(client), config()), - qmux::Session::accept(stream(server), config()) - ) - .unwrap(); - let now = moq_net::time::Instant::now(); - let client = async { - let (session, driver) = moq_net::Client::new() - .with_versions(versions.clone()) - .connect(now, client) - .await - .unwrap(); - (session, AbortOnDrop::new(tokio::spawn(moq_net::time::run(driver)))) - }; - let server = async { - let mut request = moq_net::Server::new() - .with_versions(versions.clone()) - .accept_request(now, server) - .await - .unwrap(); - let mut requests = request.requests(); - let (session, driver) = request.ok().await.unwrap(); - let (sender, receiver) = tokio::sync::mpsc::unbounded_channel(); - let acceptor = tokio::spawn(async move { - let _session = session; - let mut initial = Vec::new(); - while let Some(request) = requests.next().await { - if request.token().is_empty() { - initial.push(request.accept(auth::Grant::default())); - } else if sender.send(request).is_err() { - break; - } - } - }); - ( - receiver, - AbortOnDrop::new(tokio::spawn(moq_net::time::run(driver))), - AbortOnDrop::new(acceptor), - ) - }; - let ((client, client_task), (requests, server_task, acceptor)) = tokio::join!(client, server); - Pair { - client, - requests, - _tasks: vec![client_task, server_task], - _acceptor: acceptor, - } - } - - fn attach(connection: &kio::Producer, session: &moq_net::Session) { - let mut state = connection.write().ok().expect("connection observed"); - state.connects += 1; - replay(&state, session, state.connects); - state.session = Some(session.clone()); - } - - #[tokio::test(start_paused = true)] - async fn replay_is_registered_before_the_session_is_published() { - let connection = kio::Producer::::default(); - let auth = retained(&connection); - let mut first = pair().await; - attach(&connection, &first.client); - let (token, _issued) = tokio::join!(auth.add("replay"), async { - first.requests.recv().await.unwrap().accept(auth::Grant::all()) - }); - let token = token.unwrap(); - let mut second = pair().await; - let connects = { - let state = connection.read(); - let connects = state.connects + 1; - replay(&state, &second.client, connects); - connects - }; - // The task still observes the first session. The second token reaches the peer - // before publication only if queueing it already polled add's registration. - let request = tokio::time::timeout(std::time::Duration::from_secs(1), second.requests.recv()) - .await - .expect("token was not queued before publication") - .unwrap(); - assert_eq!(request.token().as_ref(), b"replay"); - { - let mut state = connection.write().ok().expect("connection observed"); - state.connects = connects; - state.session = Some(second.client.clone()); - } - let _issued = request.accept(auth::Grant::all()); - let mut watch = token.grant(); - while watch.peek().is_none() { - watch.changed().await.unwrap(); - } - } - - #[tokio::test(start_paused = true)] - async fn a_stale_snapshot_keeps_the_newer_sessions_registration() { - let mut first = pair().await; - let mut second = pair().await; - let replay = Replay { - bytes: "replay".into(), - queued: Mutex::new(Vec::new()), - }; - let mut draining = Draining::default(); - // The replacement queues and publishes while the task still holds the first snapshot. - replay.queue(&second.client, 2); - let request = second.requests.recv().await.unwrap(); - assert!(replay.take(1, &mut draining).is_none()); - let opening = replay - .take(2, &mut draining) - .expect("the newer session's registration was dropped"); - let _issued = request.accept(auth::Grant::all()); - let _token = opening.await.unwrap(); - // A registration for a session the task has moved past stays on it while it drains. - replay.queue(&first.client, 1); - let _stale = first.requests.recv().await.unwrap(); - assert!(replay.take(2, &mut draining).is_none()); - assert!(replay.queued.lock().unwrap().is_empty()); - assert_eq!(draining.0.len(), 1, "the draining session lost its registration"); - } - - /// A draining session that closes releases its token and its session handle at once, - /// not only when a later migration adds another. - #[tokio::test(start_paused = true)] - async fn a_closed_draining_session_releases_its_token() { - let pair = pair().await; - let mut draining = Draining::default(); - let held = Arc::new(()); - draining.push(pair.client.clone(), held.clone()); - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - assert_eq!(Arc::strong_count(&held), 2, "released while its session lives"); - pair.client.abort(moq_net::Error::Cancel); - pair.client.closed().await; - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - assert_eq!(Arc::strong_count(&held), 1, "a closed draining session kept its token"); - drop(draining); - } - - /// A migration keeps an unanswered replay on the session it leaves, whether or not the - /// task had taken it, since requests there may still be waiting on its answer. - #[tokio::test(start_paused = true)] - async fn a_draining_session_keeps_an_unanswered_replay() { - for taken in [false, true] { - let connection = kio::Producer::::default(); - let auth = retained(&connection); - let mut first = pair().await; - attach(&connection, &first.client); - let (token, _first_grant) = tokio::join!(auth.add("renew"), async { - first.requests.recv().await.unwrap().accept(auth::Grant::all()) - }); - let token = token.unwrap(); - let mut second = pair().await; - let third = pair().await; - attach(&connection, &second.client); - if taken { - // Let the task take the queued registration and wait on its answer. - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - } - attach(&connection, &third.client); - let request = tokio::time::timeout(std::time::Duration::from_secs(5), second.requests.recv()) - .await - .unwrap_or_else(|_| panic!("taken={taken}: the draining session withdrew its token unsent")) - .unwrap(); - assert_eq!(request.token().as_ref(), b"renew"); - let _issued = request.accept(auth::Grant::all()); - let mut grant = second.client.auth().grant(); - tokio::time::timeout(std::time::Duration::from_secs(5), async { - while grant.peek() != Some(auth::Grant::all()) { - grant.changed().await.unwrap(); - } - }) - .await - .unwrap_or_else(|_| panic!("taken={taken}: the draining session never applied its token")); - tokio::time::sleep(std::time::Duration::from_secs(1)).await; - assert_eq!( - second.client.auth().grant().peek(), - Some(auth::Grant::all()), - "taken={taken}: the draining session withdrew its token" - ); - drop(token); - } - } - - #[tokio::test(start_paused = true)] - async fn a_token_survives_replacement_and_withdraws_from_both_sessions() { - let connection = kio::Producer::::default(); - let auth = retained(&connection); - let mut first = pair().await; - attach(&connection, &first.client); - let (token, first_grant) = tokio::join!(auth.add("renew"), async { - let request = first.requests.recv().await.unwrap(); - assert_eq!(request.token().as_ref(), b"renew"); - request.accept(auth::Grant::all()) - }); - let token = token.unwrap(); - let mut grant = token.grant(); - assert!(grant.changed().await.unwrap().is_some()); - let mut second = pair().await; - attach(&connection, &second.client); - let request = second.requests.recv().await.unwrap(); - assert_eq!(request.token().as_ref(), b"renew"); - assert!( - grant.changed().await.unwrap().is_none(), - "replacement reused its predecessor grant" - ); - let second_grant = request.accept(auth::Grant::all()); - assert!(grant.changed().await.unwrap().is_some()); - assert!( - first_grant.closed().now_or_never().is_none(), - "migration withdrew a draining grant" - ); - drop(token); - let _ = tokio::join!(first_grant.closed(), second_grant.closed()); - } - - #[tokio::test(start_paused = true)] - async fn a_session_closing_mid_replay_keeps_the_token() { - let connection = kio::Producer::::default(); - let auth = retained(&connection); - let mut first = pair().await; - attach(&connection, &first.client); - let (token, _first_grant) = tokio::join!(auth.add("renew"), async { - first.requests.recv().await.unwrap().accept(auth::Grant::all()) - }); - let token = token.unwrap(); - let mut second = pair().await; - attach(&connection, &second.client); - // Close the replacement while its replayed token is still unanswered, before - // the connection notices. - let _unanswered = second.requests.recv().await.unwrap(); - second.client.abort(moq_net::Error::Cancel); - second.client.closed().await; - let mut third = pair().await; - attach(&connection, &third.client); - let request = tokio::time::timeout(std::time::Duration::from_secs(5), third.requests.recv()) - .await - .expect("the token was not replayed after its session closed") - .unwrap(); - assert_eq!(request.token().as_ref(), b"renew"); - drop(token); - } - - fn retained(connection: &kio::Producer) -> Handle { - Handle { - connection: connection.consume(), - pacing: Pacing::new(&super::super::Backoff::default()), - } - } - - #[tokio::test(start_paused = true)] - async fn a_transient_refusal_is_presented_again_after_backoff() { - let connection = kio::Producer::::default(); - let auth = retained(&connection); - let mut pair = pair().await; - attach(&connection, &pair.client); - let start = tokio::time::Instant::now(); - let (token, issued) = tokio::join!(auth.add("renew"), async { - for _ in 0..2 { - let outage = pair.requests.recv().await.unwrap(); - outage.reject(moq_net::SessionError::Internal, "auth server unavailable"); - } - let retried = pair.requests.recv().await.unwrap(); - assert_eq!(retried.token().as_ref(), b"renew"); - retried.accept(auth::Grant::all()) - }); - let token = token.unwrap(); - // The default pacing waits 1s, then 2s, before the third presentation. - assert!( - start.elapsed() >= std::time::Duration::from_secs(3), - "retried without backing off: {:?}", - start.elapsed() - ); - assert!(token.grant().changed().await.unwrap().is_some()); - drop(token); - issued.closed().await; - } - - #[tokio::test(start_paused = true)] - async fn an_unauthorized_refusal_is_final() { - let connection = kio::Producer::::default(); - let auth = retained(&connection); - let mut pair = pair().await; - attach(&connection, &pair.client); - let (token, ()) = tokio::join!(auth.add("renew"), async { - let refused = pair.requests.recv().await.unwrap(); - refused.reject(moq_net::SessionError::Unauthorized, "token refused"); - }); - assert!(token.is_err()); - tokio::time::sleep(std::time::Duration::from_secs(60)).await; - assert!(pair.requests.try_recv().is_err(), "a refused token was presented again"); - } - - #[tokio::test(start_paused = true)] - async fn ending_the_connection_withdraws_a_retained_token_handle() { - let connection = kio::Producer::::default(); - let auth = retained(&connection); - let mut pair = pair().await; - attach(&connection, &pair.client); - let (token, issued) = tokio::join!(auth.add("renew"), async { - pair.requests.recv().await.unwrap().accept(auth::Grant::all()) - }); - let token = token.unwrap(); - drop(connection); - assert!(token.closed().await.is_err()); - assert!(token.grant().peek().is_none()); - issued.closed().await; - } -} diff --git a/swift/Sources/Moq/Auth.swift b/swift/Sources/Moq/Auth.swift index 6bca59e7aef..67c55b1c4e9 100644 --- a/swift/Sources/Moq/Auth.swift +++ b/swift/Sources/Moq/Auth.swift @@ -21,7 +21,8 @@ public final class Auth: Sendable { /// Suspend until the grant differs from the one last reported, or held when this /// handle was made, returning it. - /// Throws once nothing will change it again, because the session ended. + /// Throws once nothing will change it again: a client's connection or a server's + /// session ended. A reconnect is not an end. public func grantChanged() async throws -> Grant? { try await ffi.grantChanged() } /// Present another token, returning once the peer accepts it. @@ -44,7 +45,8 @@ public final class AuthToken: Sendable { /// This token's own grant, or `nil` after it ended. public func grant() -> Grant? { ffi.grant() } - /// Suspend until the token ends, throwing the peer's revocation or the session's close. + /// Suspend until the token ends, throwing the peer's revocation or the end of a client's + /// connection or a server's session. A reconnect does not end it. public func closed() async throws { try await ffi.closed() } /// Withdraw the token, removing its grant from the union. diff --git a/test/wasm/uniffi/cases.ts b/test/wasm/uniffi/cases.ts index fb08d31051b..118808fb967 100644 --- a/test/wasm/uniffi/cases.ts +++ b/test/wasm/uniffi/cases.ts @@ -321,6 +321,23 @@ const remote: Record Promise> = { return closed; }, + "a token added while dialing ends with a refused dial": async (relay) => { + const session = await dial(relay, "00".repeat(32)); + // Taken before the dial settles, so the token waits for a session that never comes. + const adding = session.auth().add("token"); + const state = await settled(session); + if (state.phase !== MoqConnectionPhase.Closed) throw new Error(`settled ${state.phase}`); + const added = await settle( + adding, + 5000, + "add", + () => "resolved", + (err: unknown) => (err as Error).message, + ); + if (added === "resolved") throw new Error("add resolved after a refused dial"); + return added; + }, + "cancelling a dial closes it": async (relay) => { const session = await dial(relay); session.cancel(0);