diff --git a/CMakeLists.txt b/CMakeLists.txt index f485d32..633beb2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -143,6 +143,7 @@ if(OPENMOQ_ENABLE_PICOQUIC AND EXISTS "${OPENMOQ_PICOQUIC_SOURCE_DIR}/CMakeLists endif() add_library(openmoq_publisher_lib STATIC + src/cat4moq.cpp src/cli_options.cpp src/cmaf_segmenter.cpp src/cmsf_packager.cpp @@ -207,6 +208,12 @@ add_executable(openmoq-publisher-psychedelic-example ) target_link_libraries(openmoq-publisher-psychedelic-example PRIVATE openmoq_publisher_lib) +add_executable(openmoq-publisher-auth-example + examples/auth/AuthPublisher.cpp + examples/auth/catapult_client.cpp +) +target_link_libraries(openmoq-publisher-auth-example PRIVATE openmoq_publisher_lib) + include(CTest) if(OPENMOQ_BUILD_TESTS) @@ -246,6 +253,18 @@ if(OPENMOQ_BUILD_TESTS) target_link_libraries(openmoq-publisher-api-tests PRIVATE openmoq_publisher_lib) add_test(NAME openmoq-publisher-api-tests COMMAND openmoq-publisher-api-tests) + add_executable(openmoq-publisher-cat4moq-api-tests + tests/cat4moq_api_test.cpp + ) + target_link_libraries(openmoq-publisher-cat4moq-api-tests PRIVATE openmoq_publisher_lib) + add_test(NAME openmoq-publisher-cat4moq-api-tests COMMAND openmoq-publisher-cat4moq-api-tests) + + add_executable(openmoq-publisher-cat4moq-transport-token-tests + tests/cat4moq_transport_token_test.cpp + ) + target_link_libraries(openmoq-publisher-cat4moq-transport-token-tests PRIVATE openmoq_publisher_lib) + add_test(NAME openmoq-publisher-cat4moq-transport-token-tests COMMAND openmoq-publisher-cat4moq-transport-token-tests) + add_executable(openmoq-publisher-control-message-tests tests/moqt_control_messages_test.cpp ) diff --git a/README.md b/README.md index 7f258c9..24b5d42 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,7 @@ On Windows, replace `./build/openmoq-publisher` with `build\Release\openmoq-publ | FFmpeg input recipes | [docs/ffmpeg.md](docs/ffmpeg.md) | | Relay interoperability | [docs/relay-interop.md](docs/relay-interop.md) | | C++ Publisher API | [docs/publisher-api.md](docs/publisher-api.md) | +| CAT4MOQ auth example | [examples/auth/README.md](examples/auth/README.md) | | Protocol mapping | [docs/protocol-mapping.md](docs/protocol-mapping.md) | | WebTransport compliance | [docs/webtransport-compliance.md](docs/webtransport-compliance.md) | | Transport plan | [docs/transport-plan.md](docs/transport-plan.md) | diff --git a/docs/publisher-api.md b/docs/publisher-api.md index 49590b8..0d26534 100644 --- a/docs/publisher-api.md +++ b/docs/publisher-api.md @@ -13,6 +13,7 @@ Key types: - `openmoq::publisher::PublisherConfig` - `openmoq::publisher::Publisher` - `openmoq::publisher::PreparedPublish` +- `openmoq::publisher::cat4moq::AuthorizationConfig` ## 2. Link the Library @@ -52,7 +53,36 @@ config.subscriber_timeout = std::chrono::seconds(30); openmoq::publisher::Publisher publisher(config); ``` -## 4. Prepare Media Once (Batch Mode) +## 4. Optional CAT4MOQ Authorization + +Applications that need CAT4MOQ or other MoQ authorization-token carriage configure tokens at the public API layer. Transport internals consume this config when encoding setup, namespace, and publish request messages. + +```cpp +#include "openmoq/publisher/cat4moq.h" +#include "openmoq/publisher/publisher_api.h" + +std::vector setup_cwt = read_setup_token(); +std::vector publish_cwt = read_publish_token(); + +openmoq::publisher::PublisherConfig config; +config.authorization.setup_token = + openmoq::publisher::cat4moq::wrap_cat_token(setup_cwt); +config.authorization.action_token = + openmoq::publisher::cat4moq::wrap_cat_token(publish_cwt); +``` + +`setup_token` is carried on the session setup message. `action_token` is carried on publisher action requests such as namespace publish and track publish. Leave either field empty when that part of the relay policy does not require a token. + +Helper wrappers: + +- `wrap_cat_token(...)`: wraps raw Catapult/CAT CWT bytes as a CAT authorization-token value. +- `wrap_out_of_band_token(...)`: wraps raw private token bytes with the out-of-band token type. +- `AuthorizationToken`: stores the encoded authorization-token value sent on the wire. +- `AuthorizationConfig`: groups setup-level and action-level tokens for `PublisherConfig`. + +The runnable example in [examples/auth](../examples/auth/README.md) shows file-based tokens, Catapult command integration, and a deterministic `publish_live_objects(...)` flow against a moqx relay. + +## 5. Prepare Media Once (Batch Mode) For file or buffered stream workflows, prepare media first: @@ -78,7 +108,7 @@ This is useful for larger apps that want to: - store plan state - publish the same prepared asset to multiple endpoints -## 5. Optional: Inspect or Emit the Plan +## 6. Optional: Inspect or Emit the Plan Render the plan for logging/debug: @@ -92,7 +122,7 @@ Emit generated catalog and media objects to disk: publisher.emit_objects(prepared, "out"); ``` -## 6. Configure Endpoint and TLS +## 7. Configure Endpoint and TLS Build `EndpointConfig` and optional `TlsConfig`. @@ -126,7 +156,7 @@ tls.insecure_skip_verify = false; // tls.private_key_path = "..."; ``` -## 7. Publish Prepared Content +## 8. Publish Prepared Content Use prepared content plus endpoint: @@ -149,7 +179,7 @@ Convenience helpers: - `publish_file(path, endpoint, tls)` - `publish_stream(input, source_name, endpoint, tls)` -## 8. Live Input Publish (Incremental stdin/stream) +## 9. Live Input Publish (Incremental stdin/stream) The default live path expects fragmented MP4, which matches ffmpeg/CMAF pipelines: @@ -168,7 +198,7 @@ if (!status.ok) { `publish_live(...)` uses incremental parsing and live publish flow instead of buffering to EOF. -## 9. Arbitrary Live Object Publish +## 10. Arbitrary Live Object Publish Applications that already produce MoQ objects directly can bypass fragmented MP4 ingest with `publish_live_objects(...)`. @@ -200,7 +230,7 @@ Each `LiveObject` supplies the target track, group/object IDs, optional media timing, and the payload bytes to send. The fragmented MP4 `publish_live(...)` API remains the default live publishing path. -## 10. ALPN Override Behavior +## 11. ALPN Override Behavior By default, the API applies transport-appropriate ALPN: @@ -229,7 +259,7 @@ The same override flag exists on: - `publish_live(...)` - `publish_live_objects(...)` -## 11. Error Handling Pattern +## 12. Error Handling Pattern All API publish calls return `TransportStatus`: @@ -251,7 +281,7 @@ if (!status.ok) { } ``` -## 12. Integration Pattern for Larger Applications +## 13. Integration Pattern for Larger Applications For service-style integration: @@ -263,7 +293,7 @@ For service-style integration: 6. For direct object producers, provide a `LiveObjectSource` and call `publish_live_objects(...)`. 7. Use `TransportStatus` messages for metrics and retry decisions. -## 13. Publish Summary (`stats`) +## 14. Publish Summary (`stats`) The publisher API is blocking: `publish(...)`, `publish_file(...)`, `publish_stream(...)`, and `publish_live(...)` run the session on the calling @@ -332,7 +362,7 @@ Example: } ``` -## 14. Complete Example +## 15. Complete Example ```cpp #include "openmoq/publisher/publisher_api.h" @@ -378,7 +408,7 @@ int main() { } ``` -## 15. Live Publish with Audio/Video Encoders on Other Threads +## 16. Live Publish with Audio/Video Encoders on Other Threads `publish_live(...)` consumes one MP4 byte stream. For multi-track live publishing, the common pattern is: diff --git a/docs/quickstart.md b/docs/quickstart.md index 5483a19..fd35ef1 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -107,6 +107,33 @@ cat sample.mp4 | ./build/openmoq-publisher \ --insecure ``` +## CAT4MOQ Auth Example + +Build the CAT4MOQ auth example when testing a relay that requires MoQ authorization tokens: + +```bash +cmake --build build --target openmoq-publisher-auth-example +``` + +Run it with a token file: + +```bash +CAT4MOQ_TOKEN_FILE=/tmp/publish-token.cwt \ +CAT4MOQ_ENDPOINT='https://127.0.0.1:4433/moq-relay' \ +./examples/auth/run-cat4moq-auth-example.sh +``` + +Or run it with moqx as the Catapult/CAT4MOQ issuer command: + +```bash +CATAPULT_CAT4MOQ_COMMAND='../moqx/build/moqx issue-cat-token --config /tmp/moqx-auth.yaml --auth-service live --auth-key-id cat-dev --auth-actions client_setup,publish_namespace,publish --auth-namespace {namespace} --auth-track {track}' \ +CAT4MOQ_ENDPOINT='https://127.0.0.1:4433/moq-relay' \ +./examples/auth/run-cat4moq-auth-example.sh +``` + +See [examples/auth/README.md](../examples/auth/README.md) for the moqx auth +config, token generation, relay connection, and focused-test workflow. + ## Output Notes - default output includes the `catalog` object plus media objects diff --git a/docs/relay-interop.md b/docs/relay-interop.md index 814cd82..044a702 100644 --- a/docs/relay-interop.md +++ b/docs/relay-interop.md @@ -71,6 +71,49 @@ OPENMOQ_PICOQUIC_TRACE=1 ./build/openmoq-publisher \ `moq-relay.red5.net:4433` currently accepts WebTransport on `/moq`; `/moq-relay` returns HTTP `404` during CONNECT. The moqx relay examples use a placeholder hostname because those relay hostnames are not public yet; moqx uses `/moq-relay`. +## CAT4MOQ Authorization with moqx + +For moqx services with auth enabled, use the auth example instead of the generic CLI. It obtains CAT4MOQ token bytes from a file or Catapult command, configures `PublisherConfig::authorization`, and publishes a deterministic live-object track. + +Build the example: + +```bash +cmake --build build --target openmoq-publisher-auth-example +``` + +Run against an already-started relay: + +```bash +CAT4MOQ_TOKEN_FILE=/tmp/publish-token.cwt \ +CAT4MOQ_ENDPOINT='https://127.0.0.1:4433/moq-relay' \ +CAT4MOQ_NAMESPACE='cat4moq.example' \ +CAT4MOQ_TRACK='video' \ +./examples/auth/run-cat4moq-auth-example.sh +``` + +Run with separate setup/action tokens: + +```bash +CAT4MOQ_SETUP_TOKEN_FILE=/tmp/setup.cwt \ +CAT4MOQ_ACTION_TOKEN_FILE=/tmp/publish.cwt \ +CAT4MOQ_ENDPOINT='https://127.0.0.1:4433/moq-relay' \ +./examples/auth/run-cat4moq-auth-example.sh +``` + +Run with moqx as the Catapult/CAT4MOQ issuer command: + +```bash +CATAPULT_CAT4MOQ_COMMAND='../moqx/build/moqx issue-cat-token --config /tmp/moqx-auth.yaml --auth-service live --auth-key-id cat-dev --auth-actions client_setup,publish_namespace,publish --auth-namespace {namespace} --auth-track {track}' \ +CAT4MOQ_ENDPOINT='https://127.0.0.1:4433/moq-relay' \ +./examples/auth/run-cat4moq-auth-example.sh +``` + +When using the default CAT wrapper, configure moqx service auth with +`token_type: 16`, matching the token type wrapped by moqxr. If using +`CAT4MOQ_TOKEN_WRAPPER=out-of-band`, configure `token_type: 0`. See +[examples/auth/README.md](../examples/auth/README.md) for the local moqx auth +config, token generation, token encoding, and focused-test workflow. + ## Trace CSV If you want a per-object CSV trace for pacing and enqueue correlation, set `OPENMOQ_PICOQUIC_TRACE_CSV` alongside `OPENMOQ_PICOQUIC_TRACE`: diff --git a/examples/auth/AuthPublisher.cpp b/examples/auth/AuthPublisher.cpp new file mode 100644 index 0000000..ad6e6e6 --- /dev/null +++ b/examples/auth/AuthPublisher.cpp @@ -0,0 +1,325 @@ +#include "catapult_client.h" + +#include "openmoq/publisher/cat4moq.h" +#include "openmoq/publisher/live_object.h" +#include "openmoq/publisher/publisher_api.h" +#include "openmoq/publisher/transport/publisher_transport.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +namespace auth_example = openmoq::publisher::examples::auth; +namespace publisher = openmoq::publisher; +namespace transport = openmoq::publisher::transport; + +enum class TokenWrapper { + kCat, + kOutOfBand, + kNone, +}; + +struct Args { + std::string endpoint = "https://127.0.0.1:4433/moq"; + std::string track_namespace = "cat4moq.example"; + std::string track_name = "video"; + int seconds = 3; + bool forward = true; + bool insecure_skip_verify = true; + publisher::DraftVersion draft = publisher::DraftVersion::kDraft16; + auth_example::TokenEncoding token_encoding = auth_example::TokenEncoding::kAuto; + TokenWrapper token_wrapper = TokenWrapper::kCat; + std::optional token_file; + std::optional setup_token_file; + std::optional action_token_file; + std::optional catapult_command; +}; + +std::string require_value(int& index, int argc, char** argv, const char* flag) { + if (index + 1 >= argc) { + throw std::runtime_error(std::string("missing value for ") + flag); + } + ++index; + return argv[index]; +} + +bool parse_bool(std::string_view value, const char* flag) { + if (value == "1" || value == "true" || value == "yes") { + return true; + } + if (value == "0" || value == "false" || value == "no") { + return false; + } + throw std::runtime_error(std::string(flag) + " must be 0 or 1"); +} + +publisher::DraftVersion parse_draft(std::string_view value) { + if (value == "14") { + return publisher::DraftVersion::kDraft14; + } + if (value == "16") { + return publisher::DraftVersion::kDraft16; + } + if (value == "17") { + return publisher::DraftVersion::kDraft17; + } + if (value == "18") { + return publisher::DraftVersion::kDraft18; + } + throw std::runtime_error("--draft must be one of: 14, 16, 17, 18"); +} + +TokenWrapper parse_token_wrapper(std::string_view value) { + if (value == "cat") { + return TokenWrapper::kCat; + } + if (value == "out-of-band") { + return TokenWrapper::kOutOfBand; + } + if (value == "none") { + return TokenWrapper::kNone; + } + throw std::runtime_error("--token-wrapper must be cat, out-of-band, or none"); +} + +Args parse_args(int argc, char** argv) { + Args args; + for (int i = 1; i < argc; ++i) { + const std::string flag = argv[i]; + if (flag == "--endpoint") { + args.endpoint = require_value(i, argc, argv, "--endpoint"); + } else if (flag == "--namespace") { + args.track_namespace = require_value(i, argc, argv, "--namespace"); + } else if (flag == "--track") { + args.track_name = require_value(i, argc, argv, "--track"); + } else if (flag == "--seconds") { + args.seconds = std::stoi(require_value(i, argc, argv, "--seconds")); + if (args.seconds <= 0) { + throw std::runtime_error("--seconds must be > 0"); + } + } else if (flag == "--draft") { + args.draft = parse_draft(require_value(i, argc, argv, "--draft")); + } else if (flag == "--forward") { + args.forward = parse_bool(require_value(i, argc, argv, "--forward"), "--forward"); + } else if (flag == "--insecure-skip-verify") { + args.insecure_skip_verify = + parse_bool(require_value(i, argc, argv, "--insecure-skip-verify"), "--insecure-skip-verify"); + } else if (flag == "--token-file") { + args.token_file = std::filesystem::path(require_value(i, argc, argv, "--token-file")); + } else if (flag == "--setup-token-file") { + args.setup_token_file = std::filesystem::path(require_value(i, argc, argv, "--setup-token-file")); + } else if (flag == "--action-token-file") { + args.action_token_file = std::filesystem::path(require_value(i, argc, argv, "--action-token-file")); + } else if (flag == "--catapult-command") { + args.catapult_command = require_value(i, argc, argv, "--catapult-command"); + } else if (flag == "--token-encoding") { + args.token_encoding = auth_example::parse_token_encoding(require_value(i, argc, argv, "--token-encoding")); + } else if (flag == "--token-wrapper") { + args.token_wrapper = parse_token_wrapper(require_value(i, argc, argv, "--token-wrapper")); + } else if (flag == "--help" || flag == "-h") { + throw std::runtime_error(""); + } else { + throw std::runtime_error("unknown argument: " + flag); + } + } + return args; +} + +transport::EndpointConfig parse_endpoint(const std::string& raw) { + transport::EndpointConfig endpoint; + endpoint.transport = transport::TransportKind::kRawQuic; + endpoint.path = "/"; + endpoint.path_explicit = false; + + std::string authority = raw; + const auto consume_scheme = [&](std::string_view prefix) { + if (authority.rfind(prefix, 0) == 0) { + authority = authority.substr(prefix.size()); + return true; + } + return false; + }; + + const bool webtransport = consume_scheme("https://"); + const bool raw_quic = consume_scheme("moqt://"); + if (webtransport || raw_quic) { + endpoint.transport = webtransport ? transport::TransportKind::kWebTransport : transport::TransportKind::kRawQuic; + const std::size_t slash = authority.find('/'); + if (slash != std::string::npos) { + endpoint.path = authority.substr(slash); + endpoint.path_explicit = true; + authority = authority.substr(0, slash); + } else if (webtransport) { + endpoint.path = "/moq"; + endpoint.path_explicit = true; + } + } + + const std::size_t colon = authority.rfind(':'); + if (colon == std::string::npos || colon == 0 || colon + 1 >= authority.size()) { + throw std::runtime_error("endpoint must be host:port, moqt://host:port/path, or https://host:port/path"); + } + endpoint.host = authority.substr(0, colon); + const int port = std::stoi(authority.substr(colon + 1)); + if (port <= 0 || port > 65535) { + throw std::runtime_error("endpoint port must be between 1 and 65535"); + } + endpoint.port = static_cast(port); + return endpoint; +} + +publisher::cat4moq::AuthorizationToken wrap_token(std::vector bytes, TokenWrapper wrapper) { + if (wrapper == TokenWrapper::kCat) { + return publisher::cat4moq::wrap_cat_token(bytes); + } + if (wrapper == TokenWrapper::kOutOfBand) { + return publisher::cat4moq::wrap_out_of_band_token(bytes); + } + return publisher::cat4moq::AuthorizationToken{.bytes = std::move(bytes)}; +} + +publisher::LiveObjectSource make_source(std::string track_name, int seconds) { + constexpr int kObjectsPerSecond = 10; + const int object_count = seconds * kObjectsPerSecond; + auto index = std::make_shared(0); + + publisher::LiveObjectSource source; + source.tracks.push_back(publisher::LiveTrack{.track_name = track_name}); + source.next_object = [track_name = std::move(track_name), object_count, index]() mutable + -> std::optional { + if (*index >= object_count) { + return std::nullopt; + } + const int current = (*index)++; + const std::string payload_text = "cat4moq-auth-frame-" + std::to_string(current); + std::vector payload(payload_text.begin(), payload_text.end()); + return publisher::LiveObject{ + .track_name = track_name, + .group_id = static_cast(current / kObjectsPerSecond), + .subgroup_id = 0, + .object_id = static_cast(current % kObjectsPerSecond), + .media_time_us = static_cast(current) * 100000, + .media_duration_us = 100000, + .payload = std::move(payload), + .subgroup_contains_group_largest = (current % kObjectsPerSecond) == (kObjectsPerSecond - 1) || + current == (object_count - 1), + .final_in_subgroup = true, + }; + }; + return source; +} + +auth_example::CatapultClient make_client(const Args& args, std::optional token_file) { + auth_example::CatapultClientOptions options; + options.token_file = std::move(token_file); + options.command = args.catapult_command; + options.encoding = args.token_encoding; + return auth_example::CatapultClient(std::move(options)); +} + +void print_usage(const char* argv0) { + std::cout + << "Usage: " << argv0 << " [options]\n" + << " --endpoint URL Default: https://127.0.0.1:4433/moq\n" + << " --namespace NAME Default: cat4moq.example\n" + << " --track NAME Default: video\n" + << " --draft 14|16|17|18 Default: 16\n" + << " --seconds N Default: 3\n" + << " --forward 0|1 Default: 1\n" + << " --token-file PATH Use one token for setup and action requests\n" + << " --setup-token-file PATH Setup token source\n" + << " --action-token-file PATH Action token source\n" + << " --catapult-command COMMAND Token command; supports {action}, {namespace}, {track}, {endpoint}\n" + << " --token-encoding auto|raw|base64|hex\n" + << " --token-wrapper cat|out-of-band|none\n"; +} + +} // namespace + +int main(int argc, char** argv) { + try { + const Args args = parse_args(argc, argv); + const transport::EndpointConfig endpoint = parse_endpoint(args.endpoint); + + publisher::cat4moq::AuthorizationConfig authorization; + const std::optional setup_file = + args.setup_token_file.has_value() ? args.setup_token_file : args.token_file; + const std::optional action_file = + args.action_token_file.has_value() ? args.action_token_file : args.token_file; + + if (setup_file.has_value() || args.catapult_command.has_value()) { + auth_example::CatapultClient client = make_client(args, setup_file); + authorization.setup_token = wrap_token( + client.issue_token(auth_example::CatapultTokenRequest{ + .action = "client_setup", + .track_namespace = args.track_namespace, + .track_name = args.track_name, + .endpoint = args.endpoint, + }), + args.token_wrapper); + } + if (action_file.has_value() || args.catapult_command.has_value()) { + auth_example::CatapultClient client = make_client(args, action_file); + authorization.action_token = wrap_token( + client.issue_token(auth_example::CatapultTokenRequest{ + .action = "publish", + .track_namespace = args.track_namespace, + .track_name = args.track_name, + .endpoint = args.endpoint, + }), + args.token_wrapper); + } + + if (!authorization.setup_token.has_value() && !authorization.action_token.has_value()) { + throw std::runtime_error("no CAT4MOQ token source configured"); + } + + publisher::PublisherConfig config; + config.draft_version = args.draft; + config.track_namespace = args.track_namespace; + config.forward = args.forward; + config.publish_catalog = false; + config.paced = false; + config.subscriber_timeout = std::chrono::seconds(2); + config.authorization = std::move(authorization); + + transport::TlsConfig tls; + tls.insecure_skip_verify = args.insecure_skip_verify; + + publisher::Publisher auth_publisher(config); + const transport::TransportStatus status = + auth_publisher.publish_live_objects(make_source(args.track_name, args.seconds), endpoint, tls); + if (!status.ok) { + throw std::runtime_error("publish_live_objects failed: " + status.message); + } + + const transport::TransportStatus close_status = auth_publisher.disconnect(0); + if (!close_status.ok) { + throw std::runtime_error("disconnect failed: " + close_status.message); + } + + const auto stats = auth_publisher.stats(); + std::cout << "[cat4moq-auth] published bytes=" << stats.bytes_published + << " objects=" << stats.objects_published + << " groups=" << stats.groups_published << '\n'; + return 0; + } catch (const std::exception& error) { + if (std::string_view(error.what()).empty()) { + print_usage(argv[0]); + return 0; + } + std::cerr << "error: " << error.what() << '\n'; + print_usage(argv[0]); + return 1; + } +} diff --git a/examples/auth/CAT4MOQ_AUTH_EXAMPLE_PLAN.md b/examples/auth/CAT4MOQ_AUTH_EXAMPLE_PLAN.md new file mode 100644 index 0000000..1fbfdaa --- /dev/null +++ b/examples/auth/CAT4MOQ_AUTH_EXAMPLE_PLAN.md @@ -0,0 +1,714 @@ +# CAT4MOQ Auth Example Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` or `superpowers:executing-plans` to implement this plan task by task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a `moqxr/examples/auth` publisher example that gets CAT4MOQ credentials from Catapult and verifies them against the existing CAT4MOQ logic in the sibling `moqx` relay. + +**Architecture:** Keep auth and CAT4MOQ concepts that callers need in the public publisher API under `include/openmoq/publisher`, then have transport internals consume those API types. Keep the example as a thin executable over the existing `openmoq_publisher_lib`: it acquires CAT4MOQ tokens from Catapult or a file, passes them through the public API, publishes a deterministic media stream, and reports whether the relay accepted or rejected the authorized request. + +**Tech Stack:** C++20, existing `openmoq_publisher_lib`, picoquic/WebTransport transport path, sibling `moqx` relay, existing Catapult CAT4MOQ issuer/validator flow, CMake/CTest. + +## Global Constraints + +- Do not create additional markdown files unless explicitly authorized. +- Use text draft files under local `docs/` first when reviewing drafts. +- Use the sibling `moq-pub` publisher implementation as a reference for CAT token wrapping and test behavior. +- Treat sibling `moqx` relay CAT4MOQ and Catapult logic as existing infrastructure; do not reimplement relay-side CAT4MOQ validation in this effort. +- Any reusable auth, CAT, CAT4MOQ, action-scope, token-carriage, or token-wrapper concept needed by applications must live in public API headers under `include/openmoq/publisher`; `examples/auth` may only contain executable orchestration and Catapult command wiring. +- Do not add generated-author or co-author taglines to commits. +- No emoji in commit messages or docs unless requested. +- Prefer imports/includes over fully qualified names when a normal include/import avoids ambiguity. +- Keep diagrams readable with strongly contrasting colors if diagrams are later added. + +## Draft-Derived Requirements + +- CAT4MOQ uses CAT as the token format, represented as bytes and base64-encoded only when placed in a URL. +- The CWT `moqt` claim scopes allowed actions, namespace matches, and track-name matches. +- All actions are blocked when a token is present unless explicitly enabled by a matching `moqt` scope. +- The relevant action keys for the publisher example are `CLIENT_SETUP = 0`, `ANNOUNCE = 2`, and `PUBLISH = 6`. +- WebTransport connection authentication can carry CAT tokens in `CAT`, `CAT1`, `CAT2` query parameters or `CAT-`, `CAT1-`, `CAT2-` path components. +- Native QUIC carries path/query components through the `PATH` setup parameter. +- Per-action authorization uses the MoQ authorization/authentication token parameter on the request messages that need authorization. +- Public API users must be able to express setup-level and action-level authorization tokens without depending on transport-internal message structs. + +## File Structure + +- Create `CAT4MOQ_AUTH_EXAMPLE_PLAN.md` + - This plan. +- Create `AuthPublisher.cpp` + - Auth example executable entry point. + - Parses endpoint, namespace, token source, draft, media duration, and expected outcome. + - Requests or reads CAT4MOQ tokens and invokes `openmoq::publisher::Publisher`. +- Create `catapult_client.h` + - Small interface for token acquisition. + - Owns command-line construction and response decoding for the existing Catapult issuer. +- Create `catapult_client.cpp` + - Implements `CatapultClient::issueToken`. + - Supports deterministic offline test mode by reading a token from a file. +- Create `../../include/openmoq/publisher/cat4moq.h` + - Public CAT4MOQ API types: action enum, token wrapper helpers, and auth-token config structs used by `PublisherConfig`. +- Create `../../src/cat4moq.cpp` + - Implements public token wrapper helpers that need code outside headers. +- Modify `../../CMakeLists.txt` + - Adds `openmoq-publisher-auth-example`. + - Adds `src/cat4moq.cpp` to `openmoq_publisher_lib`. + - Adds auth example sources under `examples/auth`. +- Modify `../../include/openmoq/publisher/publisher_api.h` + - Includes `openmoq/publisher/cat4moq.h`. + - Adds a public auth configuration field to `PublisherConfig`. +- Modify `../../include/openmoq/publisher/transport/moqt_control_messages.h` + - Adds transport-internal `authorization_token` fields to setup and publish/namespace request message structs. +- Modify `../../src/transport/moqt_control_messages.cpp` + - Encodes the auth-token parameter for setup and action requests. +- Modify `../../src/transport/moqt_session.cpp` + - Propagates configured auth tokens from the public publisher API to setup and request message encoders. +- Create `../../tests/cat4moq_api_test.cpp` + - Unit tests for public CAT4MOQ token wrapper helpers and auth config defaults. +- Create `../../tests/cat4moq_transport_token_test.cpp` + - Unit tests for transport parameter encoding and draft-specific message layout. +- Create `run-cat4moq-auth-example.sh` + - Local orchestration script for starting or targeting the existing `moqx` relay, acquiring a Catapult token, running the publisher example, and checking the outcome. + +## Task 1: Confirm Existing Relay and Catapult Surfaces + +**Files:** +- Read: `../../../moqx/README.md` +- Read: `../../../moqx/RUNNING.md` +- Read: `../../../moqx/config.example.yaml` +- Read: the existing Catapult CAT4MOQ docs/config path supplied by the local Catapult setup. + +**Interfaces:** +- Consumes: sibling `moqx` relay executable or Docker image. +- Produces: exact endpoint URL, Catapult token issuance command, and relay log markers used by later tasks. + +- [ ] **Step 1: Locate the relay executable or Docker image** + +Run: + +```bash +find ../../../moqx -maxdepth 3 -type f -perm -111 -name 'moqx' -print +docker images --format '{{.Repository}}:{{.Tag}}' | rg '^moqx|openmoq/moqx|ghcr.io/openmoq/moqx' +``` + +Expected: one usable local `moqx` executable or relay image is identified. + +- [ ] **Step 2: Locate the Catapult token command** + +Run the command provided by the existing Catapult setup. If the command is exposed through an environment variable, use: + +```bash +printf '%s\n' "$CATAPULT_CAT4MOQ_COMMAND" +``` + +Expected: the command prints a CAT4MOQ token or writes one to a configured output path without requiring changes in this repo. + +- [ ] **Step 3: Record concrete local values in the implementation notes** + +Use these defaults when no project-specific override is supplied: + +```text +Relay endpoint: https://127.0.0.1:9668/moq-relay +Admin endpoint: http://127.0.0.1:9669 +Publisher namespace: example.com/bob +Publisher track: video +Draft: 16 +``` + +Expected: the example can run from `examples/auth` using defaults, while flags allow overriding every value. + +## Task 2: Add Public Auth and CAT4MOQ Publisher API + +**Files:** +- Create: `../../include/openmoq/publisher/cat4moq.h` +- Create: `../../src/cat4moq.cpp` +- Modify: `../../include/openmoq/publisher/publisher_api.h` +- Modify: `../../CMakeLists.txt` +- Create: `../../tests/cat4moq_api_test.cpp` + +**Interfaces:** +- Consumes: raw CAT/CAT4MOQ token bytes from Catapult or a test token file. +- Produces: public API types that callers use without including transport internals: + - `openmoq::publisher::cat4moq::Action` + - `openmoq::publisher::cat4moq::AuthorizationToken` + - `openmoq::publisher::cat4moq::AuthorizationConfig` + - `openmoq::publisher::cat4moq::wrap_cat_token(std::span)` + +- [ ] **Step 1: Add failing public API test** + +Create `../../tests/cat4moq_api_test.cpp`: + +```cpp +#include "openmoq/publisher/cat4moq.h" +#include "openmoq/publisher/publisher_api.h" + +#include +#include +#include +#include + +namespace { + +bool test_cat_token_wrapper() { + const std::vector cwt{0xa1, 0x18, 0x64, 0x81, 0x83}; + const auto token = openmoq::publisher::cat4moq::wrap_cat_token(cwt); + + if (token.bytes.size() != cwt.size() + 2) { + std::cerr << "wrapped token length mismatch\n"; + return false; + } + if (token.bytes[0] != 0x03 || token.bytes[1] != 0x10) { + std::cerr << "wrapped token must use alias USE_VALUE and token type CAT\n"; + return false; + } + if (!std::equal(cwt.begin(), cwt.end(), token.bytes.begin() + 2)) { + std::cerr << "wrapped token payload mismatch\n"; + return false; + } + return true; +} + +bool test_publisher_config_auth_defaults() { + openmoq::publisher::PublisherConfig config; + if (config.authorization.setup_token.has_value()) { + std::cerr << "setup token should default empty\n"; + return false; + } + if (config.authorization.action_token.has_value()) { + std::cerr << "action token should default empty\n"; + return false; + } + return true; +} + +} // namespace + +int main() { + return test_cat_token_wrapper() && test_publisher_config_auth_defaults() ? 0 : 1; +} +``` + +Expected before implementation: compilation fails because `openmoq/publisher/cat4moq.h` and `PublisherConfig::authorization` do not exist. + +- [ ] **Step 2: Define public auth API header** + +Create `../../include/openmoq/publisher/cat4moq.h`: + +```cpp +#pragma once + +#include +#include +#include +#include + +namespace openmoq::publisher::cat4moq { + +enum class Action : int { + kClientSetup = 0, + kServerSetup = 1, + kAnnounce = 2, + kSubscribeNamespace = 3, + kSubscribe = 4, + kSubscribeUpdate = 5, + kPublish = 6, + kFetch = 7, + kTrackStatus = 8, +}; + +struct AuthorizationToken { + std::vector bytes; +}; + +struct AuthorizationConfig { + std::optional setup_token; + std::optional action_token; +}; + +AuthorizationToken wrap_cat_token(std::span cwt_bytes); +AuthorizationToken wrap_out_of_band_token(std::span token_bytes); + +} // namespace openmoq::publisher::cat4moq +``` + +Expected: callers can include one public header to describe CAT4MOQ actions and authorization tokens. + +- [ ] **Step 3: Implement public token wrappers** + +Create `../../src/cat4moq.cpp`: + +```cpp +#include "openmoq/publisher/cat4moq.h" + +namespace openmoq::publisher::cat4moq { + +namespace { + +constexpr std::uint8_t kAliasUseValue = 0x03; +constexpr std::uint8_t kTokenTypeOutOfBand = 0x00; +constexpr std::uint8_t kTokenTypeCat = 0x10; + +AuthorizationToken wrap_token(std::uint8_t token_type, std::span token_bytes) { + AuthorizationToken token; + token.bytes.reserve(token_bytes.size() + 2); + token.bytes.push_back(kAliasUseValue); + token.bytes.push_back(token_type); + token.bytes.insert(token.bytes.end(), token_bytes.begin(), token_bytes.end()); + return token; +} + +} // namespace + +AuthorizationToken wrap_cat_token(std::span cwt_bytes) { + return wrap_token(kTokenTypeCat, cwt_bytes); +} + +AuthorizationToken wrap_out_of_band_token(std::span token_bytes) { + return wrap_token(kTokenTypeOutOfBand, token_bytes); +} + +} // namespace openmoq::publisher::cat4moq +``` + +Expected: wrapper behavior matches the sibling `moq-pub` token shape. + +- [ ] **Step 4: Add auth config to public publisher config** + +Modify `../../include/openmoq/publisher/publisher_api.h`: + +```cpp +#include "openmoq/publisher/cat4moq.h" +``` + +Add to `PublisherConfig`: + +```cpp +cat4moq::AuthorizationConfig authorization; +``` + +Expected: applications configure setup/action tokens through the API layer, not transport structs. + +- [ ] **Step 5: Register API source and test target** + +Modify `../../CMakeLists.txt`: + +```cmake + src/cat4moq.cpp +``` + +Insert `src/cat4moq.cpp` in the existing `openmoq_publisher_lib` source list next to `src/cli_options.cpp`. + +Add test target: + +```cmake +add_executable(openmoq-publisher-cat4moq-api-tests + tests/cat4moq_api_test.cpp +) +target_link_libraries(openmoq-publisher-cat4moq-api-tests PRIVATE openmoq_publisher_lib) +add_test(NAME openmoq-publisher-cat4moq-api-tests COMMAND openmoq-publisher-cat4moq-api-tests) +``` + +Expected: `openmoq-publisher-cat4moq-api-tests` builds and passes. + +## Task 3: Add Transport Auth-Token Carriage + +**Files:** +- Modify: `../../include/openmoq/publisher/transport/moqt_control_messages.h` +- Modify: `../../src/transport/moqt_control_messages.cpp` +- Modify: `../../src/transport/moqt_session.cpp` +- Create: `../../tests/cat4moq_transport_token_test.cpp` + +**Interfaces:** +- Consumes: `openmoq::publisher::cat4moq::AuthorizationConfig` from `PublisherConfig`. +- Produces: setup and action messages that include the configured authorization token parameter. + +- [ ] **Step 1: Add tests for setup token encoding** + +Create `../../tests/cat4moq_transport_token_test.cpp` with tests that encode a setup message containing token bytes: + +```cpp +#include "openmoq/publisher/transport/moqt_control_messages.h" + +#include +#include +#include +#include +#include + +namespace { + +bool contains_subsequence(const std::vector& haystack, + const std::vector& needle) { + for (std::size_t i = 0; i + needle.size() <= haystack.size(); ++i) { + if (std::equal(needle.begin(), needle.end(), haystack.begin() + static_cast(i))) { + return true; + } + } + return false; +} + +bool test_setup_includes_auth_token() { + using openmoq::publisher::DraftVersion; + using openmoq::publisher::transport::SetupMessage; + using openmoq::publisher::transport::TransportKind; + using openmoq::publisher::transport::encode_setup_message; + + const std::vector token{0x03, 0x10, 0xa1, 0x64, 0x6d, 0x6f, 0x71, 0x74}; + SetupMessage message; + message.draft = DraftVersion::kDraft16; + message.transport = TransportKind::kWebTransport; + message.authority = "127.0.0.1:9668"; + message.path = "/moq-relay"; + message.max_request_id = 100; + message.authorization_token = token; + + const auto encoded = encode_setup_message(message); + if (!contains_subsequence(encoded, token)) { + std::cerr << "encoded setup did not include auth token bytes\n"; + return false; + } + return true; +} + +} // namespace + +int main() { + return test_setup_includes_auth_token() ? 0 : 1; +} +``` + +Expected before implementation: compilation fails because `SetupMessage::authorization_token` does not exist. + +- [ ] **Step 2: Add token fields to transport message structs** + +Add optional byte-vector fields: + +```cpp +std::optional> authorization_token; +``` + +Apply this to `SetupMessage`, `NamespaceMessage`, and `TrackMessage`. + +Expected: tests compile far enough to fail because encoding does not yet include the token. + +- [ ] **Step 3: Encode authorization token parameters** + +In setup and request encoders, append parameter key `0x03` with the token bytes when present. Preserve the existing draft-specific parameter encoding helpers. + +Add this CMake target: + +```cmake +add_executable(openmoq-publisher-cat4moq-transport-token-tests + tests/cat4moq_transport_token_test.cpp +) +target_link_libraries(openmoq-publisher-cat4moq-transport-token-tests PRIVATE openmoq_publisher_lib) +add_test(NAME openmoq-publisher-cat4moq-transport-token-tests COMMAND openmoq-publisher-cat4moq-transport-token-tests) +``` + +Expected: `openmoq-publisher-cat4moq-transport-token-tests` passes and encoded bytes include the provided token. + +- [ ] **Step 4: Propagate public API config to transport messages** + +In `../../include/openmoq/publisher/transport/moqt_session.h`, add the public auth config as a session member and constructor parameter: + +```cpp +cat4moq::AuthorizationConfig authorization_; +``` + +In `../../src/publisher_api.cpp`, pass `PublisherConfig::authorization` into every `MoqtSession` construction path. + +In `../../src/transport/moqt_session.cpp`, copy tokens from `authorization_` into transport messages: + +```cpp +if (authorization_.setup_token) { + setup_message.authorization_token = authorization_.setup_token->bytes; +} +if (authorization_.action_token) { + namespace_message.authorization_token = authorization_.action_token->bytes; + track_message.authorization_token = authorization_.action_token->bytes; +} +``` + +Expected: existing callers continue to compile with default-empty auth config, while configured callers send tokens on setup and publish action messages. + +## Task 4: Add the Catapult Client Adapter + +**Files:** +- Create: `catapult_client.h` +- Create: `catapult_client.cpp` + +**Interfaces:** +- Consumes: endpoint, namespace, track, action list, TTL, subject, and Catapult command path. +- Produces: raw CAT4MOQ token bytes ready for publisher setup/action configuration. + +- [ ] **Step 1: Define the adapter interface** + +```cpp +#pragma once + +#include +#include +#include +#include +#include +#include + +struct CatapultTokenRequest { + std::string subject; + std::string namespace_name; + std::string track_name; + std::vector actions; + std::chrono::seconds ttl{3600}; +}; + +class CatapultClient { +public: + explicit CatapultClient(std::string command); + std::vector issueToken(const CatapultTokenRequest& request) const; + static std::vector readTokenFile(const std::filesystem::path& path); + +private: + std::string command_; +}; +``` + +Expected: interface supports both real Catapult command mode and file-backed test mode. + +- [ ] **Step 2: Implement token-file mode** + +Implement `readTokenFile` as binary read. Do not base64-decode file content unless the file starts with `base64:`. + +Expected: a binary token emitted by Catapult can be consumed without transformation. + +- [ ] **Step 3: Implement Catapult command mode** + +Invoke the configured command with explicit arguments: + +```text +--subject +--namespace +--track +--actions +--ttl-seconds +``` + +Read stdout as the token. If stdout starts with `base64:`, decode the suffix before returning bytes. + +Expected: the adapter is independent of the example executable and can be unit tested with a small shell script that prints deterministic bytes. + +## Task 5: Build the Auth Example Executable + +**Files:** +- Create: `AuthPublisher.cpp` +- Modify: `../../CMakeLists.txt` + +**Interfaces:** +- Consumes: public auth API from Task 2, transport token support from Task 3, and Catapult adapter from Task 4. +- Produces: `openmoq-publisher-auth-example`. + +- [ ] **Step 1: Implement CLI parsing** + +Support these flags: + +```text +--endpoint https://127.0.0.1:9668/moq-relay +--namespace example.com/bob +--track video +--draft 16 +--seconds 10 +--catapult-command +--cat-token-file +--subject publisher +--expect allow +--expect deny +``` + +Expected: exactly one of `--catapult-command` or `--cat-token-file` is required. + +- [ ] **Step 2: Acquire token** + +For publish authorization, request action IDs `0,2,6` from Catapult: + +```text +CLIENT_SETUP = 0 +ANNOUNCE = 2 +PUBLISH = 6 +``` + +Expected: the same token is used for setup and publish action requests. A future multi-token Catapult flow should be added as a separate change after this example works end to end. + +- [ ] **Step 3: Populate public publisher auth config** + +Convert Catapult output into the public API token type before creating the publisher: + +```cpp +openmoq::publisher::PublisherConfig config; +const auto auth_token = openmoq::publisher::cat4moq::wrap_cat_token(catapult_cwt_bytes); +config.authorization.setup_token = auth_token; +config.authorization.action_token = auth_token; +``` + +If the local Catapult command already returns complete MoQ authorization-token parameter bytes, construct the public token directly: + +```cpp +openmoq::publisher::cat4moq::AuthorizationToken auth_token; +auth_token.bytes = catapult_parameter_bytes; +config.authorization.setup_token = auth_token; +config.authorization.action_token = auth_token; +``` + +Expected: `AuthPublisher.cpp` does not include transport headers to configure auth. + +- [ ] **Step 4: Publish deterministic media** + +Reuse the generated-media pattern from `examples/psychedelic/Psychedelic.cpp`, with shorter default duration and quieter output. + +Expected: an allowed token yields a successful publisher connection and at least one published object. + +- [ ] **Step 5: Interpret expected outcome** + +If `--expect allow`, exit `0` only when publish succeeds. + +If `--expect deny`, exit `0` only when the relay rejects setup or the authorized publish action. + +Expected: the executable can be used for both positive and negative CAT4MOQ tests. + +- [ ] **Step 6: Register target** + +Add to `../../CMakeLists.txt`: + +```cmake +add_executable(openmoq-publisher-auth-example + examples/auth/AuthPublisher.cpp + examples/auth/catapult_client.cpp +) +target_link_libraries(openmoq-publisher-auth-example PRIVATE openmoq_publisher_lib) +``` + +Expected: `cmake --build build --target openmoq-publisher-auth-example` produces the example binary. + +## Task 6: Add Local Test Orchestration + +**Files:** +- Create: `run-cat4moq-auth-example.sh` + +**Interfaces:** +- Consumes: built `openmoq-publisher-auth-example`, existing `moqx` relay, existing Catapult command. +- Produces: repeatable local positive and negative CAT4MOQ test runs. + +- [ ] **Step 1: Create script inputs** + +Use environment variables: + +```bash +MOQX_BIN=${MOQX_BIN:-../../../moqx/build/moqx} +MOQX_CONFIG=${MOQX_CONFIG:-../../../moqx/config.example.yaml} +CATAPULT_CAT4MOQ_COMMAND=${CATAPULT_CAT4MOQ_COMMAND:?set CATAPULT_CAT4MOQ_COMMAND} +AUTH_EXAMPLE_BIN=${AUTH_EXAMPLE_BIN:-../../build/openmoq-publisher-auth-example} +AUTH_ENDPOINT=${AUTH_ENDPOINT:-https://127.0.0.1:9668/moq-relay} +AUTH_NAMESPACE=${AUTH_NAMESPACE:-example.com/bob} +AUTH_TRACK=${AUTH_TRACK:-video} +``` + +Expected: the script can target an already-running relay or start the configured local relay. + +- [ ] **Step 2: Run allow case** + +Invoke: + +```bash +"$AUTH_EXAMPLE_BIN" \ + --endpoint "$AUTH_ENDPOINT" \ + --namespace "$AUTH_NAMESPACE" \ + --track "$AUTH_TRACK" \ + --draft 16 \ + --seconds 5 \ + --catapult-command "$CATAPULT_CAT4MOQ_COMMAND" \ + --subject publisher \ + --expect allow +``` + +Expected: command exits `0` and prints a connection ID plus publish stats. + +- [ ] **Step 3: Run deny case** + +Use a namespace not covered by the issued token: + +```bash +"$AUTH_EXAMPLE_BIN" \ + --endpoint "$AUTH_ENDPOINT" \ + --namespace "example.com/alice" \ + --track "$AUTH_TRACK" \ + --draft 16 \ + --seconds 5 \ + --catapult-command "$CATAPULT_CAT4MOQ_COMMAND" \ + --subject publisher \ + --expect deny +``` + +Expected: command exits `0` only when the relay rejects the unauthorized action. + +## Task 7: Verification + +**Files:** +- Test: `../../tests/cat4moq_api_test.cpp` +- Test: `../../tests/cat4moq_transport_token_test.cpp` +- Test: `run-cat4moq-auth-example.sh` + +**Interfaces:** +- Consumes: all previous tasks. +- Produces: build/test evidence for the auth example. + +- [ ] **Step 1: Configure build** + +Run from `../../`: + +```bash +cmake -S . -B build -DOPENMOQ_BUILD_TESTS=ON +``` + +Expected: configuration succeeds. + +- [ ] **Step 2: Build focused targets** + +Run: + +```bash +cmake --build build --target openmoq-publisher-cat4moq-api-tests openmoq-publisher-cat4moq-transport-token-tests openmoq-publisher-auth-example +``` + +Expected: all three targets build. + +- [ ] **Step 3: Run focused unit test** + +Run: + +```bash +ctest --test-dir build -R 'openmoq-publisher-cat4moq-(api|transport-token)-tests' --output-on-failure +``` + +Expected: both focused tests pass. + +- [ ] **Step 4: Run relay integration** + +Run from `examples/auth`: + +```bash +./run-cat4moq-auth-example.sh +``` + +Expected: allow case publishes successfully and deny case is rejected by existing `moqx` CAT4MOQ logic. + +- [ ] **Step 5: Capture exact evidence** + +Record: + +```text +moqx relay command +Catapult command +publisher connection_id +allow-case exit status +deny-case exit status +relay auth log lines +``` + +Expected: evidence is sufficient to reproduce the CAT4MOQ auth example locally. diff --git a/examples/auth/README.md b/examples/auth/README.md new file mode 100644 index 0000000..c59a3aa --- /dev/null +++ b/examples/auth/README.md @@ -0,0 +1,245 @@ +# CAT4MOQ Auth Example + +This example publishes a deterministic live-object stream with CAT4MOQ authorization tokens carried through the public `openmoq::publisher` API. It is intended for local testing with the sibling `moqx` relay and its existing Catapult/CAT4MOQ verifier logic. + +The example does not implement relay-side validation. It acquires token bytes from a file or command, wraps them as a MoQ `AUTHORIZATION_TOKEN` value, configures `PublisherConfig::authorization`, and publishes through `Publisher::publish_live_objects(...)`. + +## Build + +From the repository root: + +```bash +cmake -S . -B build -DOPENMOQ_BUILD_TESTS=ON +cmake --build build --target openmoq-publisher-auth-example +``` + +The helper script builds the same target before running: + +```bash +./examples/auth/run-cat4moq-auth-example.sh +``` + +## Token Sources + +Provide one of these token sources: + +- `CAT4MOQ_TOKEN_FILE`: one token used for both setup and action requests. +- `CAT4MOQ_SETUP_TOKEN_FILE` plus `CAT4MOQ_ACTION_TOKEN_FILE`: separate token files. +- `CATAPULT_CAT4MOQ_COMMAND`: command that prints a token to stdout. + +The command may include placeholders. The example shell-quotes replacements before execution: + +- `{action}`: `client_setup` or `publish` +- `{namespace}`: configured namespace +- `{track}`: configured track name +- `{endpoint}`: configured relay endpoint + +Example: + +```bash +CATAPULT_CAT4MOQ_COMMAND='../moqx/build/moqx issue-cat-token --config /tmp/moqx-auth.yaml --auth-service live --auth-key-id cat-dev --auth-actions client_setup,publish_namespace,publish --auth-namespace {namespace} --auth-track {track}' \ +./examples/auth/run-cat4moq-auth-example.sh +``` + +Token input defaults to `auto` decoding: + +- binary input is treated as raw CWT bytes +- `base64:` is decoded as base64 +- `hex:` or `0x...` is decoded as hex +- plain printable text is passed as raw text bytes + +Override with `CAT4MOQ_TOKEN_ENCODING=raw|base64|hex|auto`. + +## Generating Tokens with moqx + +The sibling `moqx` relay can issue CAT4MOQ CWT bytes with `moqx issue-cat-token`. +The command prints `base64:` by default, which this example decodes when +`CAT4MOQ_TOKEN_ENCODING=auto` is used. Keep the default +`CAT4MOQ_TOKEN_WRAPPER=cat` so those CWT bytes are wrapped as a MoQ +`AUTHORIZATION_TOKEN` value with token type `16`. + +Generate a token directly: + +```bash +../moqx/build/moqx issue-cat-token \ + --config /tmp/moqx-auth.yaml \ + --auth-service live \ + --auth-key-id cat-dev \ + --auth-actions client_setup,publish_namespace,publish \ + --auth-namespace cat4moq.example \ + --auth-track video +``` + +For this publisher example, the broad publisher grant above is the simplest +shape: it authorizes setup, namespace publication, and track publication with +one token. The helper invokes `CATAPULT_CAT4MOQ_COMMAND` once for the setup token +and once for the action token. If your issuer uses the `{action}` placeholder, +make sure the action token still includes both `publish_namespace` and `publish` +when the relay authorizes namespace and track requests separately. + +## Token Wrapper + +The default wrapper is `CAT4MOQ_TOKEN_WRAPPER=cat`, which converts raw Catapult CWT bytes into the MoQ authorization token value: + +```text +USE_VALUE alias mode, CAT token type, CWT bytes +``` + +For local relay config, the service auth token type must match the wrapper: + +- `CAT4MOQ_TOKEN_WRAPPER=cat`: configure moqx `auth.token_type: 16` +- `CAT4MOQ_TOKEN_WRAPPER=out-of-band`: configure moqx `auth.token_type: 0` +- `CAT4MOQ_TOKEN_WRAPPER=none`: token source must already contain the full encoded authorization token value + +## Running Against moqx + +Start a moqx relay separately, or provide a relay command for the script to +start. A minimal local relay config is: + +```yaml +listeners: + - name: main + udp: + socket: + address: "::" + port: 4433 + tls: + insecure: true + endpoint: "/moq-relay" + +services: + live: + match: + - authority: {any: true} + path: {exact: "/moq-relay"} + cache: + enabled: true + max_tracks: 100 + max_groups_per_track: 3 + auth: + enabled: true + token_type: 16 + hmac_keys: + - id: "cat-dev" + secret: "replace-with-long-random-secret" + require_setup_token: true + allow_request_token_override: true + strict_claims: false +``` + +Save that as `/tmp/moqx-auth.yaml`, then run the relay: + +```bash +../moqx/build/moqx serve --config /tmp/moqx-auth.yaml +``` + +Run the auth example against the relay with moqx as the token issuer: + +```bash +CATAPULT_CAT4MOQ_COMMAND='../moqx/build/moqx issue-cat-token --config /tmp/moqx-auth.yaml --auth-service live --auth-key-id cat-dev --auth-actions client_setup,publish_namespace,publish --auth-namespace {namespace} --auth-track {track}' \ +CAT4MOQ_ENDPOINT='https://127.0.0.1:4433/moq-relay' \ +./examples/auth/run-cat4moq-auth-example.sh +``` + +Or let the script start the relay for the run: + +```bash +MOQX_RELAY_CMD='../moqx/build/moqx serve --config /tmp/moqx-auth.yaml' \ +CATAPULT_CAT4MOQ_COMMAND='../moqx/build/moqx issue-cat-token --config /tmp/moqx-auth.yaml --auth-service live --auth-key-id cat-dev --auth-actions client_setup,publish_namespace,publish --auth-namespace {namespace} --auth-track {track}' \ +CAT4MOQ_ENDPOINT='https://127.0.0.1:4433/moq-relay' \ +./examples/auth/run-cat4moq-auth-example.sh +``` + +If the relay is already running and tokens were generated separately: + +```bash +CAT4MOQ_TOKEN_FILE=/tmp/publish-token.cwt \ +CAT4MOQ_ENDPOINT='https://127.0.0.1:4433/moq-relay' \ +CAT4MOQ_NAMESPACE='cat4moq.example' \ +CAT4MOQ_TRACK='video' \ +./examples/auth/run-cat4moq-auth-example.sh +``` + +The script accepts these environment overrides: + +| Variable | Default | Meaning | +| --- | --- | --- | +| `OPENMOQ_BUILD_DIR` | `build` | Build directory containing `openmoq-publisher-auth-example` | +| `CAT4MOQ_ENDPOINT` | `https://127.0.0.1:4433/moq` | Relay endpoint | +| `CAT4MOQ_NAMESPACE` | `cat4moq.example` | Namespace published by the example | +| `CAT4MOQ_TRACK` | `video` | Track name published by the example | +| `CAT4MOQ_DRAFT` | `16` | MoQ draft version | +| `CAT4MOQ_SECONDS` | `3` | Number of deterministic live-object seconds to publish | +| `CAT4MOQ_TOKEN_ENCODING` | `auto` | Token decoding mode | +| `CAT4MOQ_TOKEN_WRAPPER` | `cat` | Token wrapper mode | +| `MOQX_RELAY_CMD` | unset | Optional command to start a local relay | +| `MOQX_RELAY_STARTUP_SECONDS` | `2` | Delay after starting `MOQX_RELAY_CMD` | + +## Direct Executable Use + +The executable can be run without the shell wrapper: + +```bash +./build/openmoq-publisher-auth-example \ + --endpoint https://127.0.0.1:4433/moq-relay \ + --namespace cat4moq.example \ + --track video \ + --draft 16 \ + --seconds 3 \ + --token-file /tmp/publish-token.cwt \ + --token-encoding auto \ + --token-wrapper cat +``` + +Use separate setup/action tokens when the relay requires distinct CAT grants: + +```bash +./build/openmoq-publisher-auth-example \ + --endpoint https://127.0.0.1:4433/moq-relay \ + --namespace cat4moq.example \ + --track video \ + --setup-token-file /tmp/setup.cwt \ + --action-token-file /tmp/publish.cwt +``` + +## Verifying the Implementation + +Build and run the focused tests from the repository root: + +```bash +cmake --build build --target \ + openmoq-publisher-auth-example \ + openmoq-publisher-cat4moq-api-tests \ + openmoq-publisher-cat4moq-transport-token-tests \ + openmoq-publisher-transport-tests + +ctest --test-dir build \ + -R 'openmoq-publisher-(transport-tests|cat4moq-(api|transport-token)-tests)' \ + --output-on-failure +``` + +Expected result: + +- public CAT4MOQ token wrapper tests pass +- setup, namespace, and publish request token-encoding tests pass +- session propagation tests confirm configured setup/action tokens reach encoded transport messages + +For a live relay run, expected success output includes: + +```text +[cat4moq-auth] published bytes=... +``` + +If the relay rejects the credentials, the executable exits non-zero and prints the publisher or transport error message. + +## API Surface Used + +The reusable pieces live in the public API: + +- `openmoq::publisher::cat4moq::AuthorizationToken` +- `openmoq::publisher::cat4moq::AuthorizationConfig` +- `openmoq::publisher::cat4moq::wrap_cat_token(...)` +- `openmoq::publisher::cat4moq::wrap_out_of_band_token(...)` +- `openmoq::publisher::PublisherConfig::authorization` + +The example directory only contains token acquisition and executable orchestration. diff --git a/examples/auth/catapult_client.cpp b/examples/auth/catapult_client.cpp new file mode 100644 index 0000000..9a5fcf6 --- /dev/null +++ b/examples/auth/catapult_client.cpp @@ -0,0 +1,260 @@ +#include "catapult_client.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#define OPENMOQ_AUTH_POPEN _popen +#define OPENMOQ_AUTH_PCLOSE _pclose +#define OPENMOQ_AUTH_POPEN_READ_MODE "rb" +#else +#define OPENMOQ_AUTH_POPEN popen +#define OPENMOQ_AUTH_PCLOSE pclose +#define OPENMOQ_AUTH_POPEN_READ_MODE "r" +#endif + +namespace openmoq::publisher::examples::auth { + +namespace { + +std::string trim_ascii(std::string value) { + while (!value.empty() && (value.back() == '\n' || value.back() == '\r' || value.back() == ' ' || value.back() == '\t')) { + value.pop_back(); + } + std::size_t first = 0; + while (first < value.size() && + (value[first] == '\n' || value[first] == '\r' || value[first] == ' ' || value[first] == '\t')) { + ++first; + } + if (first == 0) { + return value; + } + return value.substr(first); +} + +std::vector read_file(const std::filesystem::path& path) { + std::ifstream input(path, std::ios::binary); + if (!input) { + throw std::runtime_error("failed to open token file: " + path.string()); + } + return std::vector(std::istreambuf_iterator(input), {}); +} + +std::string shell_quote(std::string_view value) { + std::string quoted = "'"; + for (const char c : value) { + if (c == '\'') { + quoted += "'\\''"; + } else { + quoted.push_back(c); + } + } + quoted.push_back('\''); + return quoted; +} + +void replace_all(std::string& value, std::string_view needle, std::string_view replacement) { + std::size_t pos = 0; + while ((pos = value.find(needle, pos)) != std::string::npos) { + value.replace(pos, needle.size(), replacement); + pos += replacement.size(); + } +} + +std::string expand_command(std::string command, const CatapultTokenRequest& request) { + replace_all(command, "{action}", shell_quote(request.action)); + replace_all(command, "{namespace}", shell_quote(request.track_namespace)); + replace_all(command, "{track}", shell_quote(request.track_name)); + replace_all(command, "{endpoint}", shell_quote(request.endpoint)); + return command; +} + +std::vector read_command_stdout(const std::string& command) { + FILE* pipe = OPENMOQ_AUTH_POPEN(command.c_str(), OPENMOQ_AUTH_POPEN_READ_MODE); + if (pipe == nullptr) { + throw std::runtime_error("failed to run Catapult token command"); + } + + std::vector output; + std::array buffer{}; + while (true) { + const std::size_t bytes = std::fread(buffer.data(), 1, buffer.size(), pipe); + if (bytes > 0) { + output.insert(output.end(), buffer.begin(), buffer.begin() + static_cast(bytes)); + } + if (bytes < buffer.size()) { + if (std::ferror(pipe) != 0) { + OPENMOQ_AUTH_PCLOSE(pipe); + throw std::runtime_error("failed reading Catapult token command output"); + } + break; + } + } + + const int exit_code = OPENMOQ_AUTH_PCLOSE(pipe); + if (exit_code != 0) { + throw std::runtime_error("Catapult token command exited non-zero"); + } + return output; +} + +int hex_value(char c) { + if (c >= '0' && c <= '9') { + return c - '0'; + } + if (c >= 'a' && c <= 'f') { + return c - 'a' + 10; + } + if (c >= 'A' && c <= 'F') { + return c - 'A' + 10; + } + return -1; +} + +std::vector decode_hex(std::string text) { + text = trim_ascii(std::move(text)); + if (text.rfind("0x", 0) == 0 || text.rfind("0X", 0) == 0) { + text = text.substr(2); + } + if ((text.size() % 2) != 0) { + throw std::runtime_error("hex token output must have an even number of characters"); + } + std::vector out; + out.reserve(text.size() / 2); + for (std::size_t i = 0; i < text.size(); i += 2) { + const int high = hex_value(text[i]); + const int low = hex_value(text[i + 1]); + if (high < 0 || low < 0) { + throw std::runtime_error("hex token output contains a non-hex character"); + } + out.push_back(static_cast((high << 4) | low)); + } + return out; +} + +int base64_value(char c) { + if (c >= 'A' && c <= 'Z') { + return c - 'A'; + } + if (c >= 'a' && c <= 'z') { + return c - 'a' + 26; + } + if (c >= '0' && c <= '9') { + return c - '0' + 52; + } + if (c == '+') { + return 62; + } + if (c == '/') { + return 63; + } + return -1; +} + +std::vector decode_base64(std::string text) { + text = trim_ascii(std::move(text)); + std::vector out; + int value = 0; + int bits = -8; + for (const char c : text) { + if (c == '=') { + break; + } + if (c == '\n' || c == '\r' || c == ' ' || c == '\t') { + continue; + } + const int decoded = base64_value(c); + if (decoded < 0) { + throw std::runtime_error("base64 token output contains an invalid character"); + } + value = (value << 6) | decoded; + bits += 6; + if (bits >= 0) { + out.push_back(static_cast((value >> bits) & 0xff)); + bits -= 8; + } + } + return out; +} + +bool looks_textual_token(std::span bytes) { + for (const std::uint8_t byte : bytes) { + if (byte == '\n' || byte == '\r' || byte == '\t') { + continue; + } + if (byte < 0x20 || byte > 0x7e) { + return false; + } + } + return true; +} + +std::vector decode_token_bytes(std::vector bytes, TokenEncoding encoding) { + if (encoding == TokenEncoding::kRaw) { + return bytes; + } + + std::string text(bytes.begin(), bytes.end()); + const std::string trimmed = trim_ascii(text); + if (encoding == TokenEncoding::kBase64) { + return decode_base64(trimmed); + } + if (encoding == TokenEncoding::kHex) { + return decode_hex(trimmed); + } + if (!looks_textual_token(bytes)) { + return bytes; + } + + if (trimmed.rfind("base64:", 0) == 0) { + return decode_base64(trimmed.substr(7)); + } + if (trimmed.rfind("hex:", 0) == 0) { + return decode_hex(trimmed.substr(4)); + } + if (trimmed.rfind("0x", 0) == 0 || trimmed.rfind("0X", 0) == 0) { + return decode_hex(trimmed); + } + + bytes.assign(trimmed.begin(), trimmed.end()); + return bytes; +} + +} // namespace + +CatapultClient::CatapultClient(CatapultClientOptions options) : options_(std::move(options)) {} + +std::vector CatapultClient::issue_token(const CatapultTokenRequest& request) const { + if (options_.token_file.has_value()) { + return decode_token_bytes(read_file(*options_.token_file), options_.encoding); + } + if (options_.command.has_value()) { + return decode_token_bytes(read_command_stdout(expand_command(*options_.command, request)), options_.encoding); + } + throw std::runtime_error("configure --token-file, --setup-token-file, --action-token-file, or --catapult-command"); +} + +TokenEncoding parse_token_encoding(std::string_view value) { + if (value == "auto") { + return TokenEncoding::kAuto; + } + if (value == "raw") { + return TokenEncoding::kRaw; + } + if (value == "base64") { + return TokenEncoding::kBase64; + } + if (value == "hex") { + return TokenEncoding::kHex; + } + throw std::runtime_error("unsupported token encoding: expected auto, raw, base64, or hex"); +} + +} // namespace openmoq::publisher::examples::auth diff --git a/examples/auth/catapult_client.h b/examples/auth/catapult_client.h new file mode 100644 index 0000000..f9abc32 --- /dev/null +++ b/examples/auth/catapult_client.h @@ -0,0 +1,44 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace openmoq::publisher::examples::auth { + +enum class TokenEncoding { + kAuto, + kRaw, + kBase64, + kHex, +}; + +struct CatapultTokenRequest { + std::string action; + std::string track_namespace; + std::string track_name; + std::string endpoint; +}; + +struct CatapultClientOptions { + std::optional token_file; + std::optional command; + TokenEncoding encoding = TokenEncoding::kAuto; +}; + +class CatapultClient { +public: + explicit CatapultClient(CatapultClientOptions options); + + std::vector issue_token(const CatapultTokenRequest& request) const; + +private: + CatapultClientOptions options_; +}; + +TokenEncoding parse_token_encoding(std::string_view value); + +} // namespace openmoq::publisher::examples::auth diff --git a/examples/auth/run-cat4moq-auth-example.sh b/examples/auth/run-cat4moq-auth-example.sh new file mode 100755 index 0000000..1e7724a --- /dev/null +++ b/examples/auth/run-cat4moq-auth-example.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)" +BUILD_DIR="${OPENMOQ_BUILD_DIR:-${ROOT_DIR}/build}" + +ENDPOINT="${CAT4MOQ_ENDPOINT:-https://127.0.0.1:4433/moq}" +NAMESPACE="${CAT4MOQ_NAMESPACE:-cat4moq.example}" +TRACK="${CAT4MOQ_TRACK:-video}" +DRAFT="${CAT4MOQ_DRAFT:-16}" +SECONDS="${CAT4MOQ_SECONDS:-3}" +TOKEN_ENCODING="${CAT4MOQ_TOKEN_ENCODING:-auto}" +TOKEN_WRAPPER="${CAT4MOQ_TOKEN_WRAPPER:-cat}" + +TOKEN_FILE="${CAT4MOQ_TOKEN_FILE:-}" +SETUP_TOKEN_FILE="${CAT4MOQ_SETUP_TOKEN_FILE:-}" +ACTION_TOKEN_FILE="${CAT4MOQ_ACTION_TOKEN_FILE:-}" +TOKEN_COMMAND="${CATAPULT_CAT4MOQ_COMMAND:-${CAT4MOQ_TOKEN_COMMAND:-}}" +RELAY_COMMAND="${MOQX_RELAY_CMD:-}" + +relay_pid="" +cleanup() { + if [[ -n "${relay_pid}" ]]; then + kill "${relay_pid}" >/dev/null 2>&1 || true + wait "${relay_pid}" >/dev/null 2>&1 || true + fi +} +trap cleanup EXIT + +cmake --build "${BUILD_DIR}" --target openmoq-publisher-auth-example + +if [[ -n "${RELAY_COMMAND}" ]]; then + bash -lc "${RELAY_COMMAND}" & + relay_pid="$!" + sleep "${MOQX_RELAY_STARTUP_SECONDS:-2}" +fi + +args=( + "--endpoint" "${ENDPOINT}" + "--namespace" "${NAMESPACE}" + "--track" "${TRACK}" + "--draft" "${DRAFT}" + "--seconds" "${SECONDS}" + "--token-encoding" "${TOKEN_ENCODING}" + "--token-wrapper" "${TOKEN_WRAPPER}" +) + +if [[ -n "${TOKEN_FILE}" ]]; then + args+=("--token-file" "${TOKEN_FILE}") +fi +if [[ -n "${SETUP_TOKEN_FILE}" ]]; then + args+=("--setup-token-file" "${SETUP_TOKEN_FILE}") +fi +if [[ -n "${ACTION_TOKEN_FILE}" ]]; then + args+=("--action-token-file" "${ACTION_TOKEN_FILE}") +fi +if [[ -n "${TOKEN_COMMAND}" ]]; then + args+=("--catapult-command" "${TOKEN_COMMAND}") +fi + +if [[ -z "${TOKEN_FILE}${SETUP_TOKEN_FILE}${ACTION_TOKEN_FILE}${TOKEN_COMMAND}" ]]; then + echo "Set CAT4MOQ_TOKEN_FILE, CAT4MOQ_SETUP_TOKEN_FILE/CAT4MOQ_ACTION_TOKEN_FILE, or CATAPULT_CAT4MOQ_COMMAND." >&2 + exit 2 +fi + +"${BUILD_DIR}/openmoq-publisher-auth-example" "${args[@]}" diff --git a/include/openmoq/publisher/cat4moq.h b/include/openmoq/publisher/cat4moq.h new file mode 100644 index 0000000..c3ebccd --- /dev/null +++ b/include/openmoq/publisher/cat4moq.h @@ -0,0 +1,34 @@ +#pragma once + +#include +#include +#include +#include + +namespace openmoq::publisher::cat4moq { + +enum class Action : int { + kClientSetup = 0, + kServerSetup = 1, + kAnnounce = 2, + kSubscribeNamespace = 3, + kSubscribe = 4, + kSubscribeUpdate = 5, + kPublish = 6, + kFetch = 7, + kTrackStatus = 8, +}; + +struct AuthorizationToken { + std::vector bytes; +}; + +struct AuthorizationConfig { + std::optional setup_token; + std::optional action_token; +}; + +AuthorizationToken wrap_cat_token(std::span cwt_bytes); +AuthorizationToken wrap_out_of_band_token(std::span token_bytes); + +} // namespace openmoq::publisher::cat4moq diff --git a/include/openmoq/publisher/publisher_api.h b/include/openmoq/publisher/publisher_api.h index 89abf73..d7bcc66 100644 --- a/include/openmoq/publisher/publisher_api.h +++ b/include/openmoq/publisher/publisher_api.h @@ -13,6 +13,7 @@ #include #include "openmoq/publisher/cmsf_packager.h" +#include "openmoq/publisher/cat4moq.h" #include "openmoq/publisher/live_object.h" #include "openmoq/publisher/moq_draft.h" #include "openmoq/publisher/transport/publisher_transport.h" @@ -30,6 +31,7 @@ struct PublisherConfig { bool paced = false; bool loop = false; std::chrono::seconds subscriber_timeout = std::chrono::seconds(30); + cat4moq::AuthorizationConfig authorization; }; struct PreparedPublish { diff --git a/include/openmoq/publisher/transport/moqt_control_messages.h b/include/openmoq/publisher/transport/moqt_control_messages.h index 435a03b..405a993 100644 --- a/include/openmoq/publisher/transport/moqt_control_messages.h +++ b/include/openmoq/publisher/transport/moqt_control_messages.h @@ -19,6 +19,7 @@ struct SetupMessage { std::string authority; std::string path = "/"; std::uint64_t max_request_id = 0; + std::optional> authorization_token; }; struct ServerSetupMessage { @@ -34,6 +35,7 @@ struct NamespaceMessage { DraftVersion draft = DraftVersion::kDraft14; std::string track_namespace = "media"; std::uint64_t request_id = 0; + std::optional> authorization_token; }; struct TrackMessage { @@ -45,6 +47,7 @@ struct TrackMessage { std::size_t largest_group_id = 0; std::size_t largest_object_id = 0; bool content_exists = false; + std::optional> authorization_token; }; struct PublishNamespaceOk { diff --git a/include/openmoq/publisher/transport/moqt_session.h b/include/openmoq/publisher/transport/moqt_session.h index 1b90b63..7705e1b 100644 --- a/include/openmoq/publisher/transport/moqt_session.h +++ b/include/openmoq/publisher/transport/moqt_session.h @@ -1,6 +1,7 @@ #pragma once #include "openmoq/publisher/cmsf_packager.h" +#include "openmoq/publisher/cat4moq.h" #include "openmoq/publisher/live_object.h" #include "openmoq/publisher/transport/publisher_transport.h" @@ -13,6 +14,7 @@ #include #include #include +#include namespace openmoq::publisher::transport { @@ -49,7 +51,8 @@ class MoqtSession { bool auto_forward, bool publish_catalog, bool paced, - std::chrono::seconds subscriber_timeout); + std::chrono::seconds subscriber_timeout, + openmoq::publisher::cat4moq::AuthorizationConfig authorization = {}); explicit MoqtSession(PublisherTransport& transport, std::string track_namespace = "media", @@ -57,7 +60,8 @@ class MoqtSession { bool publish_catalog = false, bool paced = false, bool loop = false, - std::chrono::seconds subscriber_timeout = std::chrono::seconds(30)); + std::chrono::seconds subscriber_timeout = std::chrono::seconds(30), + openmoq::publisher::cat4moq::AuthorizationConfig authorization = {}); TransportStatus connect(const EndpointConfig& endpoint, const TlsConfig& tls); TransportStatus publish(const openmoq::publisher::PublishPlan& plan); @@ -76,6 +80,8 @@ class MoqtSession { private: void reset_publish_stats(); void record_published_object(const std::string& track_name, std::uint64_t group_id, std::size_t payload_bytes); + std::optional> setup_authorization_token() const; + std::optional> action_authorization_token() const; TransportStatus ensure_setup(openmoq::publisher::DraftVersion draft); TransportStatus ensure_control_stream(openmoq::publisher::DraftVersion draft); @@ -88,6 +94,7 @@ class MoqtSession { bool paced_ = false; bool loop_ = false; std::chrono::seconds subscriber_timeout_ = std::chrono::seconds(30); + openmoq::publisher::cat4moq::AuthorizationConfig authorization_; std::optional endpoint_; std::uint64_t control_stream_id_ = 0; std::uint64_t peer_control_stream_id_ = 0; diff --git a/src/cat4moq.cpp b/src/cat4moq.cpp new file mode 100644 index 0000000..47e5cb8 --- /dev/null +++ b/src/cat4moq.cpp @@ -0,0 +1,30 @@ +#include "openmoq/publisher/cat4moq.h" + +namespace openmoq::publisher::cat4moq { + +namespace { + +constexpr std::uint8_t kAliasUseValue = 0x03; +constexpr std::uint8_t kTokenTypeOutOfBand = 0x00; +constexpr std::uint8_t kTokenTypeCat = 0x10; + +AuthorizationToken wrap_token(std::uint8_t token_type, std::span token_bytes) { + AuthorizationToken token; + token.bytes.reserve(token_bytes.size() + 2); + token.bytes.push_back(kAliasUseValue); + token.bytes.push_back(token_type); + token.bytes.insert(token.bytes.end(), token_bytes.begin(), token_bytes.end()); + return token; +} + +} // namespace + +AuthorizationToken wrap_cat_token(std::span cwt_bytes) { + return wrap_token(kTokenTypeCat, cwt_bytes); +} + +AuthorizationToken wrap_out_of_band_token(std::span token_bytes) { + return wrap_token(kTokenTypeOutOfBand, token_bytes); +} + +} // namespace openmoq::publisher::cat4moq diff --git a/src/publisher_api.cpp b/src/publisher_api.cpp index ffd04d8..feef5ee 100644 --- a/src/publisher_api.cpp +++ b/src/publisher_api.cpp @@ -143,7 +143,8 @@ transport::TransportStatus Publisher::publish(const PreparedPublish& prepared, config_.publish_catalog, config_.paced, config_.loop, - config_.subscriber_timeout); + config_.subscriber_timeout, + config_.authorization); const transport::EndpointConfig resolved_endpoint = resolve_endpoint(endpoint, endpoint_alpn_overridden); set_active_session(active, resolved_endpoint, false); @@ -220,7 +221,8 @@ transport::TransportStatus Publisher::publish_live(const LiveIngestConfig& inges config_.publish_catalog, config_.paced, config_.loop, - config_.subscriber_timeout); + config_.subscriber_timeout, + config_.authorization); const transport::EndpointConfig resolved_endpoint = resolve_endpoint(endpoint, endpoint_alpn_overridden); set_active_session(active, resolved_endpoint, true); @@ -292,7 +294,8 @@ transport::TransportStatus Publisher::publish_live_objects(const LiveObjectSourc config_.publish_catalog, config_.paced, config_.loop, - config_.subscriber_timeout); + config_.subscriber_timeout, + config_.authorization); const transport::EndpointConfig resolved_endpoint = resolve_endpoint(endpoint, endpoint_alpn_overridden); set_active_session(active, resolved_endpoint, true); diff --git a/src/transport/moqt_control_messages.cpp b/src/transport/moqt_control_messages.cpp index b814e01..c474178 100644 --- a/src/transport/moqt_control_messages.cpp +++ b/src/transport/moqt_control_messages.cpp @@ -45,6 +45,7 @@ constexpr std::uint64_t kSubgroupHeaderEndOfGroupBit = 0x08; constexpr std::uint64_t kObjectDatagramTypeDraft14 = 0x10; constexpr std::uint64_t kSetupParamPath = 0x1; constexpr std::uint64_t kSetupParamMaxRequestId = 0x2; +constexpr std::uint64_t kParamAuthorizationToken = 0x3; constexpr std::uint64_t kSetupParamAuthority = 0x5; constexpr std::uint64_t kDraft14Version = 0xff00000eULL; constexpr std::uint64_t kDraft16Version = 0xff000010ULL; @@ -551,7 +552,13 @@ std::vector encode_setup_message(const SetupMessage& message) { const std::vector authority = to_bytes(message.authority); std::uint64_t previous_option_type = 0; append_setup_option_delta(payload, message.draft, previous_option_type, kSetupParamPath, path); + if (message.authorization_token.has_value()) { + append_setup_option_delta(payload, message.draft, previous_option_type, kParamAuthorizationToken, *message.authorization_token); + } append_setup_option_delta(payload, message.draft, previous_option_type, kSetupParamAuthority, authority); + } else if (message.authorization_token.has_value()) { + std::uint64_t previous_option_type = 0; + append_setup_option_delta(payload, message.draft, previous_option_type, kParamAuthorizationToken, *message.authorization_token); } std::vector message_bytes; @@ -569,7 +576,11 @@ std::vector encode_setup_message(const SetupMessage& message) { } const bool include_native_quic_location = message.transport == TransportKind::kRawQuic; - append_moqint(payload, message.draft, include_native_quic_location ? 3 : 1); + std::uint64_t parameter_count = include_native_quic_location ? 3 : 1; + if (message.authorization_token.has_value()) { + ++parameter_count; + } + append_moqint(payload, message.draft, parameter_count); std::uint64_t previous_parameter_type = 0; if (include_native_quic_location) { const std::vector path = to_bytes(message.path); @@ -585,12 +596,18 @@ std::vector encode_setup_message(const SetupMessage& message) { append_moqint(max_request_id, message.draft, message.max_request_id); if (message.draft == DraftVersion::kDraft16) { append_parameter_delta(payload, message.draft, previous_parameter_type, kSetupParamMaxRequestId, max_request_id); + if (message.authorization_token.has_value()) { + append_parameter_delta(payload, message.draft, previous_parameter_type, kParamAuthorizationToken, *message.authorization_token); + } if (include_native_quic_location) { const std::vector authority = to_bytes(message.authority); append_parameter_delta(payload, message.draft, previous_parameter_type, kSetupParamAuthority, authority); } } else { append_parameter(payload, message.draft, kSetupParamMaxRequestId, max_request_id); + if (message.authorization_token.has_value()) { + append_parameter(payload, message.draft, kParamAuthorizationToken, *message.authorization_token); + } } std::vector message_bytes; @@ -779,7 +796,12 @@ std::vector encode_namespace_message(const NamespaceMessage& messa append_moqint(payload, message.draft, 0); // Required Request ID Delta: no dependency. } append_track_namespace(payload, message.draft, message.track_namespace); - append_moqint(payload, message.draft, 0); + if (message.authorization_token.has_value()) { + append_moqint(payload, message.draft, 1); + append_parameter(payload, message.draft, kParamAuthorizationToken, *message.authorization_token); + } else { + append_moqint(payload, message.draft, 0); + } std::vector message_bytes; append_moqint(message_bytes, message.draft, kPublishNamespaceType); @@ -1374,7 +1396,12 @@ std::vector encode_track_message(const TrackMessage& message) { payload.push_back(kForwardPreference); } - append_moqint(payload, message.draft, 0); + if (message.authorization_token.has_value()) { + append_moqint(payload, message.draft, 1); + append_parameter(payload, message.draft, kParamAuthorizationToken, *message.authorization_token); + } else { + append_moqint(payload, message.draft, 0); + } if (message.draft == DraftVersion::kDraft16) { // No Track Extensions are needed for the current draft-16 publish path. } diff --git a/src/transport/moqt_session.cpp b/src/transport/moqt_session.cpp index 97131b6..fb70824 100644 --- a/src/transport/moqt_session.cpp +++ b/src/transport/moqt_session.cpp @@ -1459,6 +1459,7 @@ TransportStatus publish_selected_tracks(PublisherTransport& transport, std::span tracks, std::uint64_t peer_max_request_id, std::string_view track_namespace, + const std::optional>& authorization_token, bool paced, std::vector& pending_control_bytes, std::map& publish_stream_ids, @@ -1485,7 +1486,8 @@ TransportStatus serve_subscriptions(PublisherTransport& transport, std::map* dormant_published_tracks = nullptr, const std::map* request_id_by_track_alias = nullptr, std::uint64_t peer_max_request_id = 0, - std::uint64_t subscribe_tracks_next_request_id = 2) { + std::uint64_t subscribe_tracks_next_request_id = 2, + const std::optional>& authorization_token = std::nullopt) { std::vector buffer = std::move(pending_control_bytes); pending_control_bytes.clear(); std::set completed_request_ids; @@ -1502,6 +1504,7 @@ TransportStatus serve_subscriptions(PublisherTransport& transport, .draft = draft, .track_namespace = std::string(track_namespace), .request_id = 0, + .authorization_token = std::nullopt, }; const std::uint64_t control_read_stream_id = uses_request_streams(draft) ? peer_control_stream_id : control_stream_id; @@ -1683,6 +1686,7 @@ TransportStatus serve_subscriptions(PublisherTransport& transport, matching_tracks, peer_max_request_id, track_namespace, + authorization_token, paced, pending_control_bytes, publish_stream_ids, @@ -2251,6 +2255,7 @@ TransportStatus forward_published_tracks(PublisherTransport& transport, std::span tracks, std::uint64_t peer_max_request_id, std::string_view track_namespace, + const std::optional>& authorization_token, bool paced, std::chrono::milliseconds subscriber_timeout, std::vector& pending_control_bytes, @@ -2277,6 +2282,7 @@ TransportStatus forward_published_tracks(PublisherTransport& transport, .largest_group_id = track.largest_group_id, .largest_object_id = track.largest_object_id, .content_exists = track.content_exists, + .authorization_token = authorization_token, }; TransportStatus status = TransportStatus::success(); if (uses_request_streams(plan.draft.version)) { @@ -2434,7 +2440,12 @@ TransportStatus forward_published_tracks(PublisherTransport& transport, paced, subscriber_timeout, pending_control_bytes, - false); + false, + nullptr, + nullptr, + 0, + 2, + authorization_token); if (!status.ok) { return status; } @@ -2452,6 +2463,7 @@ TransportStatus forward_published_tracks(PublisherTransport& transport, .draft = plan.draft.version, .track_namespace = std::string(track_namespace), .request_id = 0, + .authorization_token = std::nullopt, }); } @@ -2462,6 +2474,7 @@ TransportStatus publish_selected_tracks(PublisherTransport& transport, std::span tracks, std::uint64_t peer_max_request_id, std::string_view track_namespace, + const std::optional>& authorization_token, bool paced, std::vector& pending_control_bytes, std::map& publish_stream_ids, @@ -2493,6 +2506,7 @@ TransportStatus publish_selected_tracks(PublisherTransport& transport, .largest_group_id = track.largest_group_id, .largest_object_id = track.largest_object_id, .content_exists = track.content_exists, + .authorization_token = authorization_token, }; TransportStatus status = TransportStatus::success(); if (uses_request_streams(plan.draft.version)) { @@ -2672,14 +2686,16 @@ MoqtSession::MoqtSession(PublisherTransport& transport, bool auto_forward, bool publish_catalog, bool paced, - std::chrono::seconds subscriber_timeout) + std::chrono::seconds subscriber_timeout, + openmoq::publisher::cat4moq::AuthorizationConfig authorization) : MoqtSession(transport, std::move(track_namespace), auto_forward, publish_catalog, paced, false, - subscriber_timeout) {} + subscriber_timeout, + std::move(authorization)) {} MoqtSession::MoqtSession(PublisherTransport& transport, std::string track_namespace, @@ -2687,14 +2703,16 @@ MoqtSession::MoqtSession(PublisherTransport& transport, bool publish_catalog, bool paced, bool loop, - std::chrono::seconds subscriber_timeout) + std::chrono::seconds subscriber_timeout, + openmoq::publisher::cat4moq::AuthorizationConfig authorization) : transport_(transport), track_namespace_(std::move(track_namespace)), auto_forward_(auto_forward), publish_catalog_(publish_catalog), paced_(paced), loop_(loop), - subscriber_timeout_(subscriber_timeout) {} + subscriber_timeout_(subscriber_timeout), + authorization_(std::move(authorization)) {} void MoqtSession::reset_publish_stats() { publish_stats_ = PublishStats{}; @@ -2717,6 +2735,20 @@ MoqtSession::PublishStats MoqtSession::publish_stats() const { return publish_stats_; } +std::optional> MoqtSession::setup_authorization_token() const { + if (!authorization_.setup_token.has_value()) { + return std::nullopt; + } + return authorization_.setup_token->bytes; +} + +std::optional> MoqtSession::action_authorization_token() const { + if (!authorization_.action_token.has_value()) { + return std::nullopt; + } + return authorization_.action_token->bytes; +} + TransportStatus MoqtSession::connect(const EndpointConfig& endpoint, const TlsConfig& tls) { endpoint_ = endpoint; setup_complete_ = false; @@ -2757,6 +2789,7 @@ TransportStatus MoqtSession::publish(const openmoq::publisher::PublishPlan& plan return status; } std::cout << "connection_id=" << transport_.connection_id() << '\n' << std::flush; + const auto action_token = action_authorization_token(); const std::vector tracks = build_published_tracks(plan); const LoopState loop_state = build_loop_state(plan, loop_); @@ -2765,6 +2798,7 @@ TransportStatus MoqtSession::publish(const openmoq::publisher::PublishPlan& plan .draft = plan.draft.version, .track_namespace = track_namespace_, .request_id = 0, + .authorization_token = action_token, }; if (uses_request_streams(plan.draft.version)) { status = send_request_stream_and_wait( @@ -2806,6 +2840,7 @@ TransportStatus MoqtSession::publish(const openmoq::publisher::PublishPlan& plan tracks, peer_max_request_id_, track_namespace_, + action_token, paced_, subscriber_timeout_, pending_control_bytes_, @@ -2833,6 +2868,7 @@ TransportStatus MoqtSession::publish(const openmoq::publisher::PublishPlan& plan selected_tracks, peer_max_request_id_, track_namespace_, + action_token, paced_, pending_control_bytes_, publish_stream_id_by_request_id_, @@ -2859,7 +2895,8 @@ TransportStatus MoqtSession::publish(const openmoq::publisher::PublishPlan& plan &dormant_published_tracks, &request_id_by_track_alias, peer_max_request_id_, - 2 + (selected_tracks.size() * 2)); + 2 + (selected_tracks.size() * 2), + action_token); } } @@ -2885,7 +2922,9 @@ TransportStatus MoqtSession::publish(const openmoq::publisher::PublishPlan& plan true, nullptr, nullptr, - peer_max_request_id_); + peer_max_request_id_, + 2, + action_token); } TransportStatus MoqtSession::publish_live(const LiveIngestOptions& ingest, @@ -2915,6 +2954,7 @@ TransportStatus MoqtSession::publish_live(const LiveIngestOptions& ingest, return status; } std::cout << "connection_id=" << transport_.connection_id() << '\n' << std::flush; + const auto action_token = action_authorization_token(); struct LiveMediaQueue { std::mutex mutex; @@ -2982,6 +3022,7 @@ TransportStatus MoqtSession::publish_live(const LiveIngestOptions& ingest, .draft = draft_version, .track_namespace = track_namespace_, .request_id = 0, + .authorization_token = action_token, }; if (draft_version == openmoq::publisher::DraftVersion::kDraft18) { status = send_request_stream_and_wait( @@ -3250,6 +3291,7 @@ TransportStatus MoqtSession::publish_live(std::istream& input, return status; } std::cout << "connection_id=" << transport_.connection_id() << '\n' << std::flush; + const auto action_token = action_authorization_token(); // Phase 1: Read stdin until we have ftyp + moov (track discovery). openmoq::publisher::StreamingMp4Reader reader; @@ -3304,6 +3346,7 @@ TransportStatus MoqtSession::publish_live(std::istream& input, .draft = draft_version, .track_namespace = track_namespace_, .request_id = 0, + .authorization_token = action_token, }; if (uses_request_streams(draft_version)) { status = send_request_stream_and_wait( @@ -3351,6 +3394,7 @@ TransportStatus MoqtSession::publish_live(std::istream& input, .largest_group_id = 0, .largest_object_id = 0, .content_exists = true, + .authorization_token = action_token, }; status = transport_.write_stream(control_stream_id_, encode_track_message(track_msg), false); if (!status.ok) { @@ -3591,6 +3635,7 @@ TransportStatus MoqtSession::publish_live(std::istream& input, .largest_group_id = 0, .largest_object_id = 0, .content_exists = true, + .authorization_token = action_token, }; PublishOk publish_ok; std::uint64_t track_stream_id = 0; @@ -4122,11 +4167,13 @@ TransportStatus MoqtSession::publish_live_objects(const openmoq::publisher::Live return status; } std::cout << "connection_id=" << transport_.connection_id() << '\n' << std::flush; + const auto action_token = action_authorization_token(); NamespaceMessage namespace_message{ .draft = draft_version, .track_namespace = track_namespace_, .request_id = 0, + .authorization_token = action_token, }; if (uses_request_streams(draft_version)) { status = send_request_stream_and_wait( @@ -4170,6 +4217,7 @@ TransportStatus MoqtSession::publish_live_objects(const openmoq::publisher::Live .largest_group_id = 0, .largest_object_id = 0, .content_exists = true, + .authorization_token = action_token, }; status = transport_.write_stream(control_stream_id_, encode_track_message(track_message), false); if (!status.ok) { @@ -4202,6 +4250,7 @@ TransportStatus MoqtSession::publish_live_objects(const openmoq::publisher::Live .largest_group_id = 0, .largest_object_id = 0, .content_exists = true, + .authorization_token = action_token, }; PublishOk publish_ok; std::uint64_t stream_id = 0; @@ -4720,6 +4769,7 @@ TransportStatus MoqtSession::ensure_setup(openmoq::publisher::DraftVersion draft .authority = authority, .path = endpoint_->path, .max_request_id = max_request_id, + .authorization_token = setup_authorization_token(), }); status = write_frame(control_stream_id_, setup_bytes, false); if (!status.ok) { diff --git a/tests/cat4moq_api_test.cpp b/tests/cat4moq_api_test.cpp new file mode 100644 index 0000000..b8e8e17 --- /dev/null +++ b/tests/cat4moq_api_test.cpp @@ -0,0 +1,70 @@ +#include "openmoq/publisher/cat4moq.h" +#include "openmoq/publisher/publisher_api.h" + +#include +#include +#include +#include + +namespace { + +bool test_cat_token_wrapper() { + const std::vector cwt{0xa1, 0x18, 0x64, 0x81, 0x83}; + const auto token = openmoq::publisher::cat4moq::wrap_cat_token(cwt); + + if (token.bytes.size() != cwt.size() + 2) { + std::cerr << "wrapped token length mismatch\n"; + return false; + } + if (token.bytes[0] != 0x03 || token.bytes[1] != 0x10) { + std::cerr << "wrapped token must use alias USE_VALUE and token type CAT\n"; + return false; + } + if (!std::equal(cwt.begin(), cwt.end(), token.bytes.begin() + 2)) { + std::cerr << "wrapped token payload mismatch\n"; + return false; + } + return true; +} + +bool test_out_of_band_token_wrapper() { + const std::vector raw{'s', 'e', 'c', 'r', 'e', 't'}; + const auto token = openmoq::publisher::cat4moq::wrap_out_of_band_token(raw); + + if (token.bytes.size() != raw.size() + 2) { + std::cerr << "wrapped out-of-band token length mismatch\n"; + return false; + } + if (token.bytes[0] != 0x03 || token.bytes[1] != 0x00) { + std::cerr << "wrapped out-of-band token must use alias USE_VALUE and token type OUT_OF_BAND\n"; + return false; + } + if (!std::equal(raw.begin(), raw.end(), token.bytes.begin() + 2)) { + std::cerr << "wrapped out-of-band token payload mismatch\n"; + return false; + } + return true; +} + +bool test_publisher_config_auth_defaults() { + openmoq::publisher::PublisherConfig config; + if (config.authorization.setup_token.has_value()) { + std::cerr << "setup token should default empty\n"; + return false; + } + if (config.authorization.action_token.has_value()) { + std::cerr << "action token should default empty\n"; + return false; + } + return true; +} + +} // namespace + +int main() { + return test_cat_token_wrapper() && + test_out_of_band_token_wrapper() && + test_publisher_config_auth_defaults() + ? 0 + : 1; +} diff --git a/tests/cat4moq_transport_token_test.cpp b/tests/cat4moq_transport_token_test.cpp new file mode 100644 index 0000000..bec734c --- /dev/null +++ b/tests/cat4moq_transport_token_test.cpp @@ -0,0 +1,71 @@ +#include "openmoq/publisher/transport/moqt_control_messages.h" + +#include +#include +#include +#include +#include + +namespace { + +bool contains_subsequence(const std::vector& haystack, + const std::vector& needle) { + for (std::size_t i = 0; i + needle.size() <= haystack.size(); ++i) { + if (std::equal(needle.begin(), needle.end(), haystack.begin() + static_cast(i))) { + return true; + } + } + return false; +} + +bool test_setup_includes_auth_token() { + using openmoq::publisher::DraftVersion; + using openmoq::publisher::transport::SetupMessage; + using openmoq::publisher::transport::TransportKind; + using openmoq::publisher::transport::encode_setup_message; + + const std::vector token{0x03, 0x10, 0xa1, 0x64, 0x6d, 0x6f, 0x71, 0x74}; + SetupMessage message; + message.draft = DraftVersion::kDraft16; + message.transport = TransportKind::kWebTransport; + message.authority = "127.0.0.1:9668"; + message.path = "/moq-relay"; + message.max_request_id = 100; + message.authorization_token = token; + + const auto encoded = encode_setup_message(message); + if (!contains_subsequence(encoded, token)) { + std::cerr << "encoded setup did not include auth token bytes\n"; + return false; + } + return true; +} + +bool test_publish_namespace_includes_auth_token() { + using openmoq::publisher::DraftVersion; + using openmoq::publisher::transport::NamespaceMessage; + using openmoq::publisher::transport::encode_namespace_message; + + const std::vector token{0x03, 0x10, 0xa1, 0x64, 0x6d, 0x6f, 0x71, 0x74}; + NamespaceMessage message; + message.draft = DraftVersion::kDraft16; + message.track_namespace = "example.com/bob"; + message.request_id = 0; + message.authorization_token = token; + + const auto encoded = encode_namespace_message(message); + if (!contains_subsequence(encoded, token)) { + std::cerr << "encoded namespace message did not include auth token bytes\n"; + return false; + } + return true; +} + +} // namespace + +int main() { + return test_setup_includes_auth_token() && + test_publish_namespace_includes_auth_token() + ? 0 + : 1; +} diff --git a/tests/moqt_session_test.cpp b/tests/moqt_session_test.cpp index e45275a..b1cf39b 100644 --- a/tests/moqt_session_test.cpp +++ b/tests/moqt_session_test.cpp @@ -1,4 +1,5 @@ #include "openmoq/publisher/cmsf_packager.h" +#include "openmoq/publisher/cat4moq.h" #include "openmoq/publisher/moq_draft.h" #include "openmoq/publisher/transport/moqt_control_messages.h" #include "openmoq/publisher/transport/moqt_session.h" @@ -590,6 +591,16 @@ bool bytes_equal(const std::vector& bytes, std::initializer_list(expected) == bytes; } +bool contains_subsequence(const std::vector& bytes, + const std::vector& expected) { + for (std::size_t index = 0; index + expected.size() <= bytes.size(); ++index) { + if (std::equal(expected.begin(), expected.end(), bytes.begin() + static_cast(index))) { + return true; + } + } + return false; +} + std::string hex_dump(const std::vector& bytes) { std::ostringstream out; out << std::hex << std::setfill('0'); @@ -1501,6 +1512,66 @@ int main() { ok &= expect(path == "/", "expected draft-16 CLIENT_SETUP path"); ok &= expect(max_request_id == kExpectedClientMaxRequestId, "expected draft-16 CLIENT_SETUP max_request_id"); + { + const std::vector raw_cwt{0xa1, 0x18, 0x64, 0x81, 0x83}; + const auto auth_token = openmoq::publisher::cat4moq::wrap_cat_token(raw_cwt); + openmoq::publisher::cat4moq::AuthorizationConfig auth_config; + auth_config.setup_token = auth_token; + auth_config.action_token = auth_token; + + MockTransport auth_transport; + auth_transport.reads[0].push_back(encode_server_setup_message({ + .draft = DraftVersion::kDraft16, + .max_request_id = 8, + })); + queue_subscribe_requests(auth_transport, DraftVersion::kDraft16, kTestTrackNamespace, {{1, "catalog"}, {3, "vide_1"}}); + MoqtSession auth_session(auth_transport, + std::string(kTestTrackNamespace), + false, + false, + false, + false, + std::chrono::seconds(30), + auth_config); + status = auth_session.connect(endpoint, tls); + ok &= expect(status.ok, "expected auth session connect to succeed"); + status = auth_session.publish(draft16_materialized); + ok &= expect(status.ok, "expected auth session publish to succeed"); + ok &= expect(auth_transport.writes.size() >= 2, + "expected auth session to write setup and namespace messages"); + if (auth_transport.writes.size() >= 2) { + ok &= expect(contains_subsequence(auth_transport.writes[0].bytes, auth_token.bytes), + "expected setup message to include configured CAT token"); + ok &= expect(contains_subsequence(auth_transport.writes[1].bytes, auth_token.bytes), + "expected namespace publish to include configured CAT token"); + } + + MockTransport auth_forward_transport; + auth_forward_transport.reads[0].push_back(encode_server_setup_message({ + .draft = DraftVersion::kDraft16, + .max_request_id = 8, + })); + queue_publish_ok_responses(auth_forward_transport, DraftVersion::kDraft16, {2, 4}); + MoqtSession auth_forward_session(auth_forward_transport, + std::string(kTestTrackNamespace), + true, + false, + false, + false, + std::chrono::seconds(30), + auth_config); + status = auth_forward_session.connect(endpoint, tls); + ok &= expect(status.ok, "expected auth forward session connect to succeed"); + status = auth_forward_session.publish(draft16_materialized); + ok &= expect(status.ok, "expected auth forward session publish to succeed"); + ok &= expect(auth_forward_transport.writes.size() >= 3, + "expected auth forward session to write setup, namespace, and publish messages"); + if (auth_forward_transport.writes.size() >= 3) { + ok &= expect(contains_subsequence(auth_forward_transport.writes[2].bytes, auth_token.bytes), + "expected publish track request to include configured CAT token"); + } + } + const auto draft14_wt_setup = encode_setup_message({ .draft = DraftVersion::kDraft14, .transport = openmoq::publisher::transport::TransportKind::kWebTransport,