diff --git a/quest/m1/auth/README.md b/quest/m1/auth/README.md index 1330f9854d..d9aa7c2573 100644 --- a/quest/m1/auth/README.md +++ b/quest/m1/auth/README.md @@ -90,8 +90,6 @@ existing lite-06 ALPN. ## Quests -- [Interop grants](/quest/m1/auth/interop.md) - the interop matrix asserts - each AUTH cell's grant and that a publish outside it fails loud - [Unauthorized reset](/quest/m1/auth/unauthorized.md) - a subscription that loses access resets with a dedicated UNAUTHORIZED stream code - [Path patterns](/quest/m1/auth/patterns.md) - one matcher for every path diff --git a/quest/m1/auth/bindings.md b/quest/m1/auth/bindings.md index b4a1912420..d3aad8a50f 100644 --- a/quest/m1/auth/bindings.md +++ b/quest/m1/auth/bindings.md @@ -24,9 +24,9 @@ mid-stream can be handed a new one without the plugin reconnecting. async calls use, in `rs/libmoq/src/api.rs` and the session table; regenerate `moq.h`, and update `cpp/obs/src` only if the plugin surfaces a token field, otherwise leave it. -- Interop: the Python, Go, and C interop clients print their grant and join - the assertion [Interop grants](/quest/m1/auth/interop.md) adds for Rust - and JS. +- Interop: the Python, Go, and C interop clients print their grant as the + `auth granted publish=[...] subscribe=[...]` line and join `prints_grant` and + `enforces_grant` in `test/interop/interop.sh`, beside Rust and JS. - Wrappers: `py/moq-rs/moq/session.py`, `swift/Sources/Moq`, `kt/.../Flows.kt` (a `Flow` over `grant_changed`), `go/wrapper/moq/session.go` (context-cancellable like the rest), and `dart/moq/lib/moq.dart`. Kotlin diff --git a/quest/m1/auth/interop.md b/quest/m1/auth/interop.md deleted file mode 100644 index a376e202f7..0000000000 --- a/quest/m1/auth/interop.md +++ /dev/null @@ -1,21 +0,0 @@ -# [S] Interop asserts the AUTH grant - -## Goal - -`just test interop --all` fails a cell with AUTH (lite-06, or moq-transport -draft-17+ with MoQ Auth) whose client did not receive -the grant its relay token implies, and a new negative round passes only when -a client publishing outside its grant fails loud with `Unauthorized`. Today -the matrix checks media alone, so a malformed AUTH_OK that leaves a session -with no grant passes silently. - -## Plan - -The Rust and JS interop clients each print the grant they received in one -parseable line; `test/interop/interop.sh` -compares it against the token the cell minted. The negative round mints a -token that excludes the published path and expects the publisher's session -to close with `Unauthorized` naming the path, and the subscriber to see -nothing. Cells without AUTH skip both checks, as do binding clients until -[Bindings](/quest/m1/auth/bindings.md) gives them a grant to print; that -quest adds them to the same assertion. No public API or wire change. diff --git a/rs/moq-ffi/src/binary.rs b/rs/moq-ffi/src/binary.rs index 8e343bd2ad..3c231757f7 100644 --- a/rs/moq-ffi/src/binary.rs +++ b/rs/moq-ffi/src/binary.rs @@ -48,7 +48,9 @@ impl MoqBroadcastProducer { let _guard = crate::ffi::enter(); self.with_state(|state| { let track = state.broadcast.create_track(name, None)?; - let producer = state.catalog.binary_snapshot(track, config.into())?; + let producer = state + .catalog + .binary_snapshot(track, moq_mux::binary::Config::from(config))?; Ok(Arc::new(MoqBinarySnapshotProducer { inner: std::sync::Mutex::new(Some(producer)), })) @@ -66,7 +68,9 @@ impl MoqBroadcastProducer { let _guard = crate::ffi::enter(); self.with_state(|state| { let track = state.broadcast.create_track(name, None)?; - let producer = state.catalog.binary_stream(track, config.into())?; + let producer = state + .catalog + .binary_stream(track, moq_mux::binary::Config::from(config))?; Ok(Arc::new(MoqBinaryStreamProducer { inner: std::sync::Mutex::new(Some(producer)), })) diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index ceee1b52fa..f7ce89cfe0 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -295,6 +295,14 @@ impl Handle { /// Record an AUTH_OK for the token. pub(crate) fn granted(&self, id: u64, grant: Grant) { + // One parseable line per AUTH_OK, so the grant the peer actually sent is observable + // without an API; the interop harness checks it against the token it minted. + let list = |patterns: &Patterns| format!("{:?}", patterns.iter().map(|p| p.to_string()).collect::>()); + tracing::debug!( + publish = %list(&grant.publish), + subscribe = %list(&grant.subscribe), + "auth granted" + ); let mut state = self.state.lock(); let Some(slot) = state.tokens.get_mut(&id) else { return; diff --git a/test/interop/README.md b/test/interop/README.md index e41a0d7179..725d5740d5 100644 --- a/test/interop/README.md +++ b/test/interop/README.md @@ -59,6 +59,30 @@ than this checkout: it's a prebuilt NAPI QUIC/HTTP3 addon, not part of the moq source tree. Everything else (`@moq/net`, `@moq/hang`, ...) resolves to the workspace packages, because the JS clients here are bun workspace members. +## Auth + +The relay verifies tokens through `moq auth serve` with a key generated for the +run; nothing is anonymous. Each publisher dials with a token for its broadcast's +subtree and each subscriber with one to read it, so every cell also covers the +`?jwt=` URL path in every client. + +Clients that print the grant the relay sent back over AUTH, as an +`auth granted publish=[...] subscribe=[...]` line, must report exactly what +their token implies: the Rust CLI (a `moq_net::auth` debug log) and the native +JS subscribers. A cell whose grant is missing or wrong fails even when media +flowed. The binding clients (Python, Go, C, GStreamer) have no grant to print +until moq-ffi exposes one, and the browser's shared connection keeps its session +private, so their cells check media alone. Every client here negotiates +moq-lite-06, so a printing client that reports nothing never got its grant. + +After the matrix, each publisher whose refusal the harness can read (Rust, the +browser) runs once more with a token that excludes its broadcast. It must fail +loud, logging Unauthorized and naming the path, and every subscriber must time +out. + +Tokens grant subtrees (`name/**`) because moq-lite-06's AUTH_OK carries prefixes: +the relay withholds a literal grant it cannot encode, and the client sees none. + ## Running locally You need the workspace toolchain on `PATH` (cargo, ffmpeg, bun, uv, go, @@ -161,7 +185,7 @@ contract](../README.md). ```text interop.sh orchestrator: build clients, run the relay + matrix or media checks -interop.toml relay config (anonymous, self-signed localhost) +interop.toml relay config (token auth via `moq auth serve`, self-signed localhost) clients/ python/interop.py publish/subscribe via py/moq-rs (import moq) go/main.go publish/subscribe via go/wrapper (import moq-go/moq) diff --git a/test/interop/clients/js-native/subscribe.ts b/test/interop/clients/js-native/subscribe.ts index 59ec48610a..3a23bacc6b 100644 --- a/test/interop/clients/js-native/subscribe.ts +++ b/test/interop/clients/js-native/subscribe.ts @@ -51,6 +51,16 @@ if (role !== "subscribe" || !url || !broadcast || !Number.isFinite(timeoutMs) || async function run(): Promise { const origin = new Moq.Origin.Producer(); const connection = await Moq.Connection.connect({ url: new URL(url as string), consume: origin }); + // The grant the relay sent, in the Rust client's `auth granted` shape, so the harness can + // check it against the token this cell minted. + const printGrant = (grant: Moq.Auth.Grant | undefined) => { + if (!grant) return; + const publish = JSON.stringify(grant.publish); + const subscribe = JSON.stringify(grant.subscribe); + console.error(`auth granted publish=${publish} subscribe=${subscribe}`); + }; + printGrant(connection.auth.grant.peek()); + const unwatch = connection.auth.grant.subscribe(printGrant); let requested: Moq.Origin.Requesting | undefined; try { const path = Moq.Path.from(broadcast as string); @@ -104,6 +114,7 @@ async function run(): Promise { } throw new Error("no frame data received"); } finally { + unwatch(); requested?.close(); connection.close(); // returns void, not a promise origin.close(); diff --git a/test/interop/interop.sh b/test/interop/interop.sh index 713d4e6865..895674360c 100755 --- a/test/interop/interop.sh +++ b/test/interop/interop.sh @@ -12,6 +12,12 @@ # broadcast and confirms every subscriber sees data flowing before the timeout. # Every publisher but the Rust CLI also carries audio. The browser subscriber # verifies rendered WebCodecs output, player pause/resume, and that audio. +# +# Every client dials with a token minted for its cell and verified by +# `moq auth serve`. Clients that print the grant the relay sent back over AUTH +# must report exactly what their token implies, and a final round per enforcing +# publisher mints a token that excludes its broadcast: the publisher must fail +# loud with Unauthorized and no subscriber may see data. set -euo pipefail INTEROP_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) @@ -34,6 +40,7 @@ SIZE="${INTEROP_SIZE:-320x240}" # Empty means "any reserved port"; INTEROP_PORT pins one instead. PORT="${INTEROP_PORT:-}" URL="" +KEY="" # the HMAC key every cell's token is signed with (set once the relay's auth server starts) NEGATIVE=0 MEDIA=0 @@ -385,10 +392,34 @@ if harness_probe "$URL/certificate.sha256"; then exit 1 fi +# The relay's auth server: `moq auth serve` verifying every token this run mints +# against one fresh key. It answers only POST, so readiness is any HTTP reply. +harness_port auth +AUTH_URL="http://127.0.0.1:${HARNESS_PORT}/" +auth_up() { curl -s -o /dev/null --max-time 1 "$AUTH_URL"; } +if auth_up; then + echo "error: something is already listening on $AUTH_URL" >&2 + exit 1 +fi +KEY="$HARNESS_RUN/key.jwk" +"$MOQ" auth generate --out "$KEY" +harness_spawn auth "$HARNESS_RUN/auth.log" "$MOQ" auth serve --listen "127.0.0.1:${HARNESS_PORT}" --key "$KEY" +AUTH_PID="$HARNESS_PID" +deadline=$((SECONDS + 30)) +until auth_up; do + if ((SECONDS >= deadline)) || harness_exited "$AUTH_PID"; then + echo "auth server never became ready" >&2 + sed 's/^/ auth: /' "$HARNESS_RUN/auth.log" >&2 || true + exit 1 + fi + sleep 0.05 +done + echo "starting relay on 127.0.0.1:${PORT}..." -# interop.toml is the source of truth; rewrite its port into a scratch copy so the +# interop.toml is the source of truth; rewrite its ports into a scratch copy so the # committed file never has to be edited for a run. -sed "s/4443/${PORT}/g" "$INTEROP_DIR/interop.toml" >"$HARNESS_RUN/relay.toml" +sed -e "s|:4443\"|:${PORT}\"|g" -e "s|http://127.0.0.1:4440/|${AUTH_URL}|" \ + "$INTEROP_DIR/interop.toml" >"$HARNESS_RUN/relay.toml" harness_spawn relay "$HARNESS_RUN/relay.log" "$RELAY" "$HARNESS_RUN/relay.toml" if ! harness_ready "$URL/certificate.sha256" 30 "$HARNESS_PID"; then echo "relay never became ready" >&2 @@ -397,6 +428,54 @@ if ! harness_ready "$URL/certificate.sha256" 30 "$HARNESS_PID"; then fi harness_endpoint relay "$URL" +# ── tokens ────────────────────────────────────────────────────────────────── +# Print the relay URL carrying a fresh token; the arguments are `moq auth sign`'s +# (`--publish P`, `--subscribe S`). +token_url() { + local token + token=$("$MOQ" auth sign --key "$KEY" "$@") + printf '%s/?jwt=%s' "$URL" "$token" +} + +# moq-cli logs each grant it receives over AUTH at debug, under `moq_net::auth`. +CLI_LOG="${RUST_LOG:-info},moq_net::auth=debug" + +# The clients that print the grant they received as an `auth granted` line. The +# binding clients (python, go, c, gst) have no grant to print until moq-ffi +# exposes one, and the browser's shared connection keeps its session private. +prints_grant() { + case "$1" in + rust | js-native-node | js-native-bun) return 0 ;; + *) return 1 ;; + esac +} + +# The `auth granted` line a token minted with at most one publish and one +# subscribe pattern implies. Every client on this relay negotiates moq-lite-06, +# which carries AUTH, so a client that prints nothing never got its grant. +grant_line() { + local publish="${1:+\"$1\"}" subscribe="${2:+\"$2\"}" + printf 'auth granted publish=[%s] subscribe=[%s]' "$publish" "$subscribe" +} + +# The last grant a client printed to , with ANSI colour dropped and list +# separators normalized so the Rust and JS renderings compare equal. +reported_grant() { + sed 's/\x1b\[[0-9;]*m//g' "$1" 2>/dev/null | + grep -o 'auth granted publish=\[[^]]*\] subscribe=\[[^]]*\]' | + tail -n 1 | sed 's/", "/","/g' || true +} + +# Check that 's log reports ; prints why not and fails otherwise. +check_grant() { + local lang="$1" log="$2" expected="$3" got + prints_grant "$lang" || return 0 + got=$(reported_grant "$log") + [[ "$got" == "$expected" ]] && return 0 + echo "grant: got '${got:-nothing}', token implies '$expected'" + return 1 +} + # ── client dispatch ───────────────────────────────────────────────────────── # Encode an endless H.264 Annex-B stream from a synthetic source to stdout. # Paced with -re so the broadcast streams in real time until the reader closes. @@ -416,23 +495,23 @@ ffmpeg_h264() { # importers only frame-and-forward). # shellcheck disable=SC2329 # invoked indirectly via 'harness_spawn' run_publisher() { - local lang="$1" broadcast="$2" + local lang="$1" broadcast="$2" url="$3" case "$lang" in rust) - ffmpeg_h264 | "$MOQ" --connect "$URL" --broadcast "$broadcast" import avc3 + ffmpeg_h264 | RUST_LOG="$CLI_LOG" "$MOQ" --connect "$url" --broadcast "$broadcast" import avc3 ;; python) ffmpeg_h264 | "$PY" "$CLIENTS/python/interop.py" \ - publish --url "$URL" --broadcast "$broadcast" + publish --url "$url" --broadcast "$broadcast" ;; go) - ffmpeg_h264 | "$GO_INTEROP" publish --url "$URL" --broadcast "$broadcast" + ffmpeg_h264 | "$GO_INTEROP" publish --url "$url" --broadcast "$broadcast" ;; js) # Headless Chromium encodes its own H.264 from a fake camera via # WebCodecs (lazily, once a subscriber creates demand). cd "$CLIENTS/js" && bun driver.ts publish \ - --url "$URL" --broadcast "$broadcast" + --url "$url" --broadcast "$broadcast" ;; *) echo "unknown publisher: $lang" >&2 @@ -441,11 +520,12 @@ run_publisher() { esac } +# start_publisher , logging to pub-.log. # Sets global PUB_PID to the publisher's process group leader. PUB_PID="" start_publisher() { - local lang="$1" broadcast="$2" - harness_spawn "pub-$lang" "$HARNESS_RUN/pub-$lang.log" run_publisher "$lang" "$broadcast" + local round="$1" lang="$2" broadcast="$3" url="$4" + harness_spawn "pub-$round" "$HARNESS_RUN/pub-$round.log" run_publisher "$lang" "$broadcast" "$url" PUB_PID="$HARNESS_PID" } @@ -464,25 +544,25 @@ run_native() { # shellcheck disable=SC2329 # reached from a function 'harness_spawn' invokes run_subscriber() { - local lang="$1" broadcast="$2" publisher="${3:-}" + local lang="$1" broadcast="$2" url="$3" publisher="${4:-}" case "$lang" in rust) # moq-cli only handles SIGINT, so -k forces SIGKILL if it ignores the # SIGTERM that fires when no data arrives within the timeout. local n - n=$(timeout -k 3 "$TIMEOUT" "$MOQ" --connect "$URL" --broadcast "$broadcast" \ + n=$(RUST_LOG="$CLI_LOG" timeout -k 3 "$TIMEOUT" "$MOQ" --connect "$url" --broadcast "$broadcast" \ export fmp4 | head -c 1 | wc -c | tr -d ' ' || true) [[ "${n:-0}" -ge 1 ]] ;; python) "$PY" "$CLIENTS/python/interop.py" \ - subscribe --url "$URL" --broadcast "$broadcast" --timeout "$TIMEOUT" + subscribe --url "$url" --broadcast "$broadcast" --timeout "$TIMEOUT" ;; go) - "$GO_INTEROP" subscribe --url "$URL" --broadcast "$broadcast" --timeout "$TIMEOUT" + "$GO_INTEROP" subscribe --url "$url" --broadcast "$broadcast" --timeout "$TIMEOUT" ;; c) - "$C_INTEROP" subscribe --url "$URL" --broadcast "$broadcast" --timeout "$TIMEOUT" + "$C_INTEROP" subscribe --url "$url" --broadcast "$broadcast" --timeout "$TIMEOUT" ;; gst) # moqsrc exposes each rendition as a Sometimes pad (video_%u / audio_%u), @@ -499,7 +579,7 @@ run_subscriber() { local n n=$(GST_PLUGIN_PATH_1_0="$GST_PLUGIN_DIR" GST_REGISTRY_1_0="$HARNESS_RUN/gst-run-registry.bin" \ timeout -k 3 "$TIMEOUT" gst-launch-1.0 -q \ - moqsrc name=s url="$URL" broadcast="$broadcast" \ + moqsrc name=s url="$url" broadcast="$broadcast" \ s.video_0 ! filesink location=/dev/stdout buffer-mode=2 \ 2>/dev/null | head -c 1 | wc -c | tr -d ' ' || true) [[ "${n:-0}" -ge 1 ]] @@ -511,21 +591,21 @@ run_subscriber() { # FFI clients from a synthetic Opus tone), so validate audio there. if [[ "$publisher" != "rust" ]]; then (cd "$CLIENTS/js" && bun driver.ts subscribe \ - --url "$URL" --broadcast "$broadcast" --timeout "$TIMEOUT" --expect-audio) + --url "$url" --broadcast "$broadcast" --timeout "$TIMEOUT" --expect-audio) else (cd "$CLIENTS/js" && bun driver.ts subscribe \ - --url "$URL" --broadcast "$broadcast" --timeout "$TIMEOUT") + --url "$url" --broadcast "$broadcast" --timeout "$TIMEOUT") fi ;; js-native-bun) # Native @moq/net via moq's WebTransport polyfill, under bun. run_native bun subscribe.ts subscribe \ - --url "$URL" --broadcast "$broadcast" --timeout "$TIMEOUT" + --url "$url" --broadcast "$broadcast" --timeout "$TIMEOUT" ;; js-native-node) # Same, under node (tsx runs the TS directly). run_native node --import tsx subscribe.ts subscribe \ - --url "$URL" --broadcast "$broadcast" --timeout "$TIMEOUT" + --url "$url" --broadcast "$broadcast" --timeout "$TIMEOUT" ;; *) echo "unknown subscriber: $lang" >&2 @@ -546,53 +626,58 @@ overall=0 # creeping up on $TIMEOUT is a near-miss worth seeing before it fails. # shellcheck disable=SC2329 # invoked indirectly via 'harness_spawn' run_cell() { - local pub="$1" sub="$2" broadcast="$3" started=$SECONDS status=0 + local pub="$1" sub="$2" broadcast="$3" url="$4" started=$SECONDS status=0 # `|| status=$?` rather than a bare call: under `set -e` a failing subscriber # would exit before it recorded anything, and a failure is exactly when the # duration is worth reading. - run_subscriber "$sub" "$broadcast" "$pub" || status=$? + run_subscriber "$sub" "$broadcast" "$url" "$pub" || status=$? echo "$((SECONDS - started))" >"$HARNESS_RUN/$pub-$sub.secs" return "$status" } +# run_round : every subscriber dials with a +# token for alone, and must see data (want_pass=1) or time out (0). +# names the publisher in the output and the logs. run_round() { - local pub="$1" broadcast="$2" pub_pid="$3" - local pids=() names=() i sub + local pub="$1" broadcast="$2" pub_pid="$3" want_pass="$4" + local pids=() names=() i sub sub_url sub_grant why + sub_url=$(token_url --subscribe "$broadcast/**") + sub_grant=$(grant_line "" "$broadcast/**") for sub in "${SUB_LIST[@]}"; do if is_broken "$sub"; then echo " FAIL $pub -> $sub (subscriber client unavailable)" overall=1 continue fi - harness_spawn "$pub-$sub" "$HARNESS_RUN/$pub-$sub.log" run_cell "$pub" "$sub" "$broadcast" + harness_spawn "$pub-$sub" "$HARNESS_RUN/$pub-$sub.log" run_cell "$pub" "$sub" "$broadcast" "$sub_url" pids+=("$HARNESS_PID") names+=("$sub") done # A publisher that streams forever should still be alive; if it died, the # subscriber failures below are a publisher bug, so surface its log. - if [[ -n "$pub_pid" ]] && ! kill -0 "$pub_pid" 2>/dev/null; then + if [[ "$want_pass" -eq 1 && -n "$pub_pid" ]] && ! kill -0 "$pub_pid" 2>/dev/null; then echo " WARN publisher '$pub' exited early:" sed 's/^/ /' "$HARNESS_RUN/pub-$pub.log" 2>/dev/null || true fi - local want_pass=1 got round_pass=0 elapsed - [[ "$NEGATIVE" -eq 1 ]] && want_pass=0 + local got round_pass=0 elapsed # ${arr[@]+...} guard: a round may have no live subscribers (all broken), # and bash 3.2 (macOS) errors on "${!pids[@]}" for an empty array under `set -u`. for i in ${pids[@]+"${!pids[@]}"}; do + why="" if harness_wait "${pids[$i]}"; then got=1; else got=0; fi elapsed=$(cat "$HARNESS_RUN/$pub-${names[$i]}.secs" 2>/dev/null || echo "?") - if [[ "$got" -eq "$want_pass" ]]; then + if [[ "$got" -eq "$want_pass" ]] && why=$(check_grant "${names[$i]}" "$HARNESS_RUN/$pub-${names[$i]}.log" "$sub_grant"); then echo " PASS $pub -> ${names[$i]} (${elapsed}s)" round_pass=1 else - echo " FAIL $pub -> ${names[$i]} (${elapsed}s of ${TIMEOUT}s)" + echo " FAIL $pub -> ${names[$i]} (${elapsed}s of ${TIMEOUT}s)${why:+ $why}" sed 's/^/ /' "$HARNESS_RUN/$pub-${names[$i]}.log" 2>/dev/null || true overall=1 fi done # Every leg failing points at the publisher; surface its log even when the # process is still alive (e.g. connected and announcing but producing nothing). - if [[ "$NEGATIVE" -eq 0 && "$round_pass" -eq 0 && ${#pids[@]} -gt 0 && -n "$pub_pid" ]]; then + if [[ "$want_pass" -eq 1 && "$round_pass" -eq 0 && ${#pids[@]} -gt 0 && -n "$pub_pid" ]]; then echo " INFO publisher '$pub' log:" sed 's/^/ /' "$HARNESS_RUN/pub-$pub.log" 2>/dev/null || true fi @@ -611,7 +696,7 @@ run_media() { shift log="$HARNESS_RUN/media-${name//[^[:alnum:]._-]/-}.log" started=$SECONDS - (cd "$CLIENTS/js" && bun media.ts --url "$URL" --timeout "$TIMEOUT" "$@") >"$log" 2>&1 || status=$? + (cd "$CLIENTS/js" && bun media.ts --url "$MEDIA_URL" --timeout "$TIMEOUT" "$@") >"$log" 2>&1 || status=$? if [[ "$status" -eq 0 ]]; then echo " PASS $name ($((SECONDS - started))s)" # The measurements are the point even when nothing fails: a skew or frame rate creeping @@ -624,7 +709,26 @@ run_media() { fi } +# The publishers whose refusal the harness can read: the Rust CLI's log and the +# browser page's console. The binding publishers enforce the grant too, but +# surface it only through their bindings. +enforces_grant() { + case "$1" in + rust | js) return 0 ;; + *) return 1 ;; + esac +} + +# Check that a publisher refused for failed loud: Unauthorized, naming the path. +check_denied() { + local log="$1" broadcast="$2" plain + plain=$(sed 's/\x1b\[[0-9;]*m//g' "$log" 2>/dev/null || true) + grep -qi 'unauthorized' <<<"$plain" && grep -q "outside our grant.*$broadcast" <<<"$plain" +} + if [[ "$MEDIA" -eq 1 ]]; then + # One token for the whole run: every media case publishes and watches its own broadcast. + MEDIA_URL=$(token_url --publish '**' --subscribe '**') # Media output and lifecycle, browser to browser, against the deterministic fixture. The # negative controls below inject a defect and name the assertion that has to catch it; each # passes only by failing there, which is what keeps the positive run from being vacuous. @@ -643,7 +747,7 @@ elif [[ "$NEGATIVE" -eq 1 ]]; then # Negative control: no publisher. Every subscriber must FAIL (time out with # no data), proving the harness can actually report failure. echo "=== negative control: subscribers expect NO data ===" - run_round "none" "interop-missing-$$-$RANDOM.hang" "" + run_round "none" "interop-missing-$$-$RANDOM.hang" "" 0 else for pub in "${PUB_LIST[@]}"; do broadcast="interop-${pub}-$$-${RANDOM}.hang" @@ -655,8 +759,36 @@ else overall=1 continue fi - start_publisher "$pub" "$broadcast" - run_round "$pub" "$broadcast" "$PUB_PID" + start_publisher "$pub" "$pub" "$broadcast" "$(token_url --publish "$broadcast/**")" + run_round "$pub" "$broadcast" "$PUB_PID" 1 + if why=$(check_grant "$pub" "$HARNESS_RUN/pub-$pub.log" "$(grant_line "$broadcast/**" "")"); then + prints_grant "$pub" && echo " PASS $pub grant" + else + echo " FAIL $pub $why" + overall=1 + fi + done + + # A publisher whose token excludes its broadcast must abort its session with + # Unauthorized naming the path, and no subscriber may see the broadcast. + for pub in "${PUB_LIST[@]}"; do + if ! enforces_grant "$pub" || is_broken "$pub"; then continue; fi + broadcast="interop-denied-${pub}-$$-${RANDOM}.hang" + allowed="interop-allowed-$$" + echo "=== publisher: $pub broadcast: $broadcast (token grants only $allowed/**) ===" + start_publisher "$pub-denied" "$pub" "$broadcast" "$(token_url --publish "$allowed/**")" + run_round "$pub-denied" "$broadcast" "$PUB_PID" 0 + log="$HARNESS_RUN/pub-$pub-denied.log" + if ! check_denied "$log" "$broadcast"; then + echo " FAIL $pub publisher did not fail with Unauthorized naming $broadcast:" + sed 's/^/ /' "$log" 2>/dev/null || true + overall=1 + elif ! why=$(check_grant "$pub" "$log" "$(grant_line "$allowed/**" "")"); then + echo " FAIL $pub $why" + overall=1 + else + echo " PASS $pub publisher refused: Unauthorized" + fi done fi diff --git a/test/interop/interop.toml b/test/interop/interop.toml index 083145e27e..59ac7ab87d 100644 --- a/test/interop/interop.toml +++ b/test/interop/interop.toml @@ -1,5 +1,5 @@ # Relay config for the cross-language interop test. -# Anonymous access, self-signed localhost cert, QUIC + WebSocket on 127.0.0.1:4443. +# Token auth, self-signed localhost cert, QUIC + WebSocket on 127.0.0.1:4443. [log] level = "info" @@ -14,5 +14,6 @@ tls.generate = ["localhost", "127.0.0.1"] listen = "127.0.0.1:4443" [auth] -# Allow anonymous access to everything. -public = "**" +# `moq auth serve`, verifying the tokens interop.sh mints for each cell. No +# anonymous access: a client that drops its token is refused. +url = "http://127.0.0.1:4440/"