From 564c73f202c80348424120bc88a3958ea94c9602 Mon Sep 17 00:00:00 2001 From: Michal Hosna Date: Thu, 6 Aug 2026 10:00:10 +0000 Subject: [PATCH 1/4] auth: add anonymous_claim grants and per-message token cap - anonymous_claim: service config statically grants specific actions/namespaces to every request, with or without a token; checked alongside session and per-request token grants in authorize(). - MatchRuleType moves to Action.h. AnonymousScope's match mode now reuses it directly instead of duplicating it as a separate MatchMode enum. - actionName() and fromCatapultMatchType() gain a scoped -Wswitch-as-error. An unhandled enum case now fails the build instead of silently falling through. fromCatapultMatchType also logs when it hits that case, since its input comes from catapult's decoder, not our own code. - max_tokens_per_message: caps AUTHORIZATION_TOKEN params verified per message (setup and per-request), inheritable from service_defaults.auth; bounds verification cost against a client attaching many tokens to one message. - Action-name canonicalization (aliases, numeric IDs, case/dash-insensitive) moves to Action.h, shared by config validation, Auth.cpp, and the moqx-issuer CLI, replacing three separate copies. Header-only and moxygen-free, so moqx_config_loader can validate names without linking moqx_core. --- config.example.yaml | 11 +- docs/config.md | 59 +++++- src/MoqxRelayContext.cpp | 1 + src/auth/Action.h | 103 +++++++++ src/auth/Auth.cpp | 165 ++++++++++----- src/auth/Auth.h | 53 +++-- src/auth/AuthTokenIssuer.cpp | 51 +---- src/config/Config.h | 15 ++ src/config/ConfigResolver.cpp | 191 ++++++++++++++--- src/config/ConfigSerializer.h | 46 ++++ src/config/loader/ParsedConfig.h | 73 +++++++ test/AuthTest.cpp | 306 +++++++++++++++++++++++++++ test/MoqxRelayContextTest.cpp | 60 ++++++ test/config/ConfigResolverTest.cpp | 212 +++++++++++++++++++ test/config/ConfigSerializerTest.cpp | 32 ++- test/relay/AuthFiltersTest.cpp | 87 ++++++++ 16 files changed, 1306 insertions(+), 159 deletions(-) create mode 100644 src/auth/Action.h diff --git a/config.example.yaml b/config.example.yaml index 897e50687..13e9cd429 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -25,7 +25,6 @@ listeners: # max_data: 33554432 # Override connection flow control window for this listener service_defaults: - # Default cache settings inherited by all services. # Per-service cache merges field-by-field: set only the fields you want to override. cache: enabled: true # Enable relay cache @@ -39,6 +38,10 @@ service_defaults: # default_max_cache_duration_s: 300 # Default TTL (seconds) for tracks without a publisher-set duration. # Absent: use max_cache_duration_s. # 0: opt-in-only (don't cache tracks unless publisher sets a duration). + # Required (directly or via this block) whenever a service sets auth.enabled: true. + auth: + max_tokens_per_message: 4 # Caps AUTHORIZATION_TOKEN params of the service's token_type per + # message; a message over the cap is rejected outright, not truncated. services: live-streaming: @@ -70,8 +73,12 @@ services: # - id: "cat-dev" # Key ID carried in the token envelope; selects which secret verifies it # secret: "replace-with-long-random-secret" # Shared secret used by moqx-issuer # require_setup_token: true # Require a client_setup token during CLIENT_SETUP - # allow_request_token_override: true # Allow a per-request token to override the session's setup grants + # allow_request_token_override: true # Let a per-request token count as one more candidate grant, alongside setup-token grants and anonymous_claim # strict_claims: false # Keep false for current CAT4MOQ interop unless all issuers send only supported claims + # max_tokens_per_message: 4 # Required directly or via service_defaults.auth; caps AUTHORIZATION_TOKEN params verified per message + # anonymous_claim: # Statically-granted scopes applied to every request, with or without a token + # - actions: [subscribe, fetch] # Anyone can subscribe/fetch under "live/..." without a token + # namespace_match: {prefix: ["live"]} testing: match: diff --git a/docs/config.md b/docs/config.md index 4fe4ba052..61b0d265f 100644 --- a/docs/config.md +++ b/docs/config.md @@ -28,8 +28,9 @@ listener_defaults: # optional; QUIC defaults inherited by all listeners listeners: # required; at least one - { ... } -service_defaults: # optional; cache defaults inherited by all services +service_defaults: # optional; cache and auth defaults inherited by all services cache: { ... } + auth: { max_tokens_per_message: 4 } services: # required; at least one my-service: @@ -165,6 +166,10 @@ services: require_setup_token: true allow_request_token_override: true strict_claims: false + max_tokens_per_message: 4 + anonymous_claim: + - actions: [subscribe, fetch] + namespace_match: {prefix: ["live"]} ``` | Field | Default | Notes | @@ -172,13 +177,60 @@ services: | `enabled` | `false` | Enables CAT-style authorization for this service. | | `token_type` | `0` | MOQT `AUTHORIZATION_TOKEN` type to accept. Use `16` with CAT4MOQ tokens produced for moqxr's CAT wrapper. Type `0` is valid for private or out-of-band deployments. The value must fit in a QUIC variable integer. | | `hmac_keys` | empty | Required when `enabled: true`. Each key needs a non-empty `id` and `secret`; duplicate key IDs are rejected. The token issuer must use the same key ID and secret. | -| `require_setup_token` | `true` | Requires a valid setup token authorizing `client_setup` during CLIENT_SETUP. If `false`, clients can connect without setup grants, but publish/subscribe requests still need an authorized setup or request token. | -| `allow_request_token_override` | `true` | Allows a token on a request to replace the session setup grants for that request. If `false`, request tokens are ignored and authorization uses only the setup token grants. | +| `require_setup_token` | `true` | Requires a valid setup token authorizing `client_setup` during session setup. If `false`, clients can connect without setup grants; per-request actions still need an authorized token or a matching `anonymous_claim` entry. | +| `allow_request_token_override` | `true` | Lets a token on a request count as one more candidate grant for that request, alongside the session's setup-token grants and `anonymous_claim`. If `false`, request tokens are ignored and authorization uses only the setup token grants and `anonymous_claim`. | | `strict_claims` | `false` | Rejects unsupported claims when `true`. Keep this `false` for current CAT4MOQ interop unless every issuer is known to send only supported claims. | +| `max_tokens_per_message` | none | Required when `enabled: true`, directly or via `service_defaults.auth.max_tokens_per_message`. Caps how many `AUTHORIZATION_TOKEN` params of the configured `token_type` a single SETUP or request message may carry; a message over the cap is rejected outright, not truncated. Params of other token types are not counted. Bounds per-message verification cost against a client attaching many tokens to one message. Must be >= 1. | +| `anonymous_claim` | empty | Statically-granted scopes applied to every request on this service, regardless of what token (if any) authenticated it. See [Anonymous Claim](#anonymous-claim) below. | The relay only verifies tokens; it does not call an external grant handler. Grant decisions are encoded by the token issuer as CAT4MOQ actions and scopes. +A message (SETUP or any per-request message) may carry more than one +`AUTHORIZATION_TOKEN` of the configured `token_type`, up to +`max_tokens_per_message`; a message carrying more than that is rejected +outright. Within the cap, a request is authorized if +any one of the tokens present verifies and covers the action; a token that +fails to verify is simply not counted as a candidate rather than failing the +request outright, as long as some other token, the session's setup grants, or +`anonymous_claim` covers it. + +### Anonymous Claim + +`anonymous_claim` grants a floor of access to every request on the service, +whether or not the request carries a token at all, whether or not a setup +token was presented, and regardless of what any presented token grants. It +never authorizes `client_setup`/`server_setup` — connecting still needs a +valid setup token whenever `require_setup_token: true`; the claim only ever +widens what an already-connected session (or an anonymous one, when +`require_setup_token: false`) can do per request. + +```yaml +auth: + enabled: true + hmac_keys: [{id: "cat-dev", secret: "replace-with-long-random-secret"}] + require_setup_token: true + max_tokens_per_message: 4 + anonymous_claim: + - actions: [subscribe, fetch] + namespace_match: {prefix: ["live"]} + track_match: {exact: "video"} +``` + +Each entry: +- `actions` — one or more action names from the table below (aliases and + numeric IDs accepted, same as `moqx-issuer`'s `--auth-actions`). + `client_setup`/`server_setup` are rejected. +- `namespace_match` — optional; a list of namespace segments matched by + `exact`, `prefix`, `suffix`, or `contains`. Omit to match any namespace. +- `track_match` — optional; a single track name matched by `exact`, `prefix`, + `suffix`, or `contains`. Omit to match any track. + +The four match modes are the same `BinaryMatchType` (exact/prefix/suffix/contains) +CAT4MOQ tokens use for their own namespace/track claims, so an `anonymous_claim` +scope matches namespaces and tracks with identical semantics to a token-granted +scope. + ### Issuing Tokens Use the standalone `moqx-issuer` binary as the deployment/operator tool. For @@ -284,6 +336,7 @@ key ID, waiting for old tokens to expire, then removing the old key. namespace, or wrong track is rejected. - Auth is currently service-local. Upstream relay connections still have no application-level credential exchange beyond TLS. +- `max_tokens_per_message` is inheritable from `service_defaults.auth` --- diff --git a/src/MoqxRelayContext.cpp b/src/MoqxRelayContext.cpp index 02e0704fb..63d594954 100644 --- a/src/MoqxRelayContext.cpp +++ b/src/MoqxRelayContext.cpp @@ -237,6 +237,7 @@ folly::Expected MoqxRelayContext::validateAu case auth::AuthError::Forbidden: case auth::AuthError::Missing: case auth::AuthError::WrongTokenType: + case auth::AuthError::TooManyTokens: return folly::makeUnexpected(SessionCloseErrorCode::UNAUTHORIZED); } return folly::makeUnexpected(SessionCloseErrorCode::UNAUTHORIZED); diff --git a/src/auth/Action.h b/src/auth/Action.h new file mode 100644 index 000000000..f73cec410 --- /dev/null +++ b/src/auth/Action.h @@ -0,0 +1,103 @@ +/* + * Copyright (c) OpenMOQ contributors. + * This source code is licensed under the Apache 2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +#pragma once + +#include + +#include +#include +#include +#include +#include +#include + +// CAT4MOQ actions and their canonical names. Lightweight (moxygen-free) on +// purpose. +namespace openmoq::moqx::auth { + +enum class Action : uint64_t { + ClientSetup = 0, + ServerSetup = 1, + PublishNamespace = 2, + SubscribeNamespace = 3, + Subscribe = 4, + RequestUpdate = 5, + Publish = 6, + Fetch = 7, + TrackStatus = 8, +}; + +enum class MatchRuleType : uint64_t { Exact = 0, Prefix = 1, Suffix = 2, Contains = 3 }; + +#pragma GCC diagnostic push +#pragma GCC diagnostic error "-Wswitch" +inline std::string_view actionName(Action action) { + switch (action) { + case Action::ClientSetup: + return "client_setup"; + case Action::ServerSetup: + return "server_setup"; + case Action::PublishNamespace: + return "publish_namespace"; + case Action::SubscribeNamespace: + return "subscribe_namespace"; + case Action::Subscribe: + return "subscribe"; + case Action::RequestUpdate: + return "request_update"; + case Action::Publish: + return "publish"; + case Action::Fetch: + return "fetch"; + case Action::TrackStatus: + return "track_status"; + } + return "unknown"; +} +#pragma GCC diagnostic pop + +// Canonicalizes an action name (aliases, numeric IDs, case/dash-insensitive) +// to its Action, or nullopt if unrecognized. +inline std::optional canonicalAction(std::string_view name) { + std::string normalized = + folly::trimWhitespace(folly::StringPiece(name.data(), name.size())).str(); + std::replace(normalized.begin(), normalized.end(), '-', '_'); + std::transform(normalized.begin(), normalized.end(), normalized.begin(), [](unsigned char c) { + return static_cast(std::tolower(c)); + }); + + if (normalized == "client_setup" || normalized == "setup" || normalized == "0") { + return Action::ClientSetup; + } + if (normalized == "server_setup" || normalized == "1") { + return Action::ServerSetup; + } + if (normalized == "publish_namespace" || normalized == "announce" || normalized == "2") { + return Action::PublishNamespace; + } + if (normalized == "subscribe_namespace" || normalized == "3") { + return Action::SubscribeNamespace; + } + if (normalized == "subscribe" || normalized == "4") { + return Action::Subscribe; + } + if (normalized == "request_update" || normalized == "subscribe_update" || normalized == "5") { + return Action::RequestUpdate; + } + if (normalized == "publish" || normalized == "6") { + return Action::Publish; + } + if (normalized == "fetch" || normalized == "7") { + return Action::Fetch; + } + if (normalized == "track_status" || normalized == "8") { + return Action::TrackStatus; + } + return std::nullopt; +} + +} // namespace openmoq::moqx::auth diff --git a/src/auth/Auth.cpp b/src/auth/Auth.cpp index 6db4a3abd..d16868857 100644 --- a/src/auth/Auth.cpp +++ b/src/auth/Auth.cpp @@ -30,18 +30,6 @@ using namespace moxygen; namespace openmoq::moqx::auth { namespace { -std::string canonicalNamespace(const TrackNamespace& ns) { - std::string out; - for (const auto& field : ns.trackNamespace) { - out.push_back(static_cast((field.size() >> 24) & 0xff)); - out.push_back(static_cast((field.size() >> 16) & 0xff)); - out.push_back(static_cast((field.size() >> 8) & 0xff)); - out.push_back(static_cast(field.size() & 0xff)); - out.append(field); - } - return out; -} - std::vector toBytes(std::string_view value) { return std::vector( reinterpret_cast(value.data()), @@ -53,6 +41,11 @@ std::string toString(const std::vector& bytes) { return std::string(reinterpret_cast(bytes.data()), bytes.size()); } +// catapult's CBOR decoder (parse_bin_match, cwt.cpp) falls back to EXACT for +// an unrecognized BinaryMatchType, so the XLOG below is a tripwire against +// that decoder changing; -Wswitch (a hard error here) covers a missed case. +#pragma GCC diagnostic push +#pragma GCC diagnostic error "-Wswitch" MatchRule::Type fromCatapultMatchType(catapult::BinaryMatchType type) { switch (type) { case catapult::BinaryMatchType::EXACT: @@ -64,8 +57,11 @@ MatchRule::Type fromCatapultMatchType(catapult::BinaryMatchType type) { case catapult::BinaryMatchType::CONTAINS: return MatchRule::Type::Contains; } + XLOG(ERR) << "catapult BinaryMatchType " << static_cast(type) + << " unrecognized; falling back to EXACT"; return MatchRule::Type::Exact; } +#pragma GCC diagnostic pop std::vector fromCatapultMatch(const catapult::MoqtCompoundMatch& match) { if (match.is_empty()) { @@ -109,6 +105,60 @@ Grants grantsFromToken(const catapult::CatToken& token) { return grants; } +Grants buildAnonymousGrants(const std::vector& configScopes) { + Grants grants; // expiresAt stays time_point::max() -- a static claim never expires + for (const auto& configScope : configScopes) { + Scope scope; + scope.actions = configScope.actions; + if (configScope.namespaceSegments) { + scope.namespaceMatches.push_back(MatchRule{ + .type = configScope.namespaceMatchMode, + .value = canonicalNamespace(TrackNamespace{*configScope.namespaceSegments}), + }); + } + if (configScope.trackName) { + scope.trackMatches.push_back(MatchRule{ + .type = configScope.trackMatchMode, + .value = *configScope.trackName, + }); + } + grants.scopes.push_back(std::move(scope)); + } + return grants; +} + +struct VerifiedTokens { + std::vector grants; + std::optional firstError; +}; + +// Picks the error for a denial after token verification: with at least one +// verified token the problem is scope (Forbidden), not the crypto/format +// problem `firstError` holds for the *other* tokens that failed to verify. +AuthError +denialError(const std::vector& verifiedGrants, std::optional firstError) { + return verifiedGrants.empty() ? firstError.value_or(AuthError::Forbidden) : AuthError::Forbidden; +} + +// Verifies every token, pooling grants from the ones that succeed. A token +// that fails to verify is dropped as a non-viable candidate; `firstError` +// keeps the earliest failure for a caller with nothing else to report. +VerifiedTokens +verifyTokens(const AuthTokenVerifier& verifier, const std::vector& tokens) { + VerifiedTokens result; + for (const auto& token : tokens) { + auto verified = verifier.verify(token); + if (verified.hasError()) { + if (!result.firstError) { + result.firstError = verified.error(); + } + continue; + } + result.grants.push_back(std::move(verified.value())); + } + return result; +} + } // namespace AuthTokenVerifier::AuthTokenVerifier(config::AuthConfig config) : config_(std::move(config)) { @@ -120,6 +170,7 @@ AuthTokenVerifier::AuthTokenVerifier(config::AuthConfig config) : config_(std::m keyIdIndex_.emplace(key.id, idx); } } + anonymousGrants_ = buildAnonymousGrants(config_.anonymousClaim); } folly::Expected AuthTokenVerifier::verify(const AuthToken& token) const { @@ -186,36 +237,25 @@ authenticateSetup(const AuthTokenVerifier& verifier, const Parameters& setupPara } auto tokens = findAuthTokens(setupParams, verifier.tokenType()); - std::vector verifiedGrants; - std::optional firstError; - for (const auto& token : tokens) { - auto verified = verifier.verify(token); - if (verified.hasError()) { - if (!firstError) { - firstError = verified.error(); - } - continue; - } - verifiedGrants.push_back(std::move(verified.value())); + if (tokens.size() > verifier.maxTokensPerMessage()) { + XLOG(WARN) << "authenticateSetup: " << tokens.size() << " setup tokens exceeds " + << "max_tokens_per_message=" << verifier.maxTokensPerMessage() << "; rejecting"; + return folly::makeUnexpected(AuthError::TooManyTokens); } + auto [verifiedGrants, firstError] = verifyTokens(verifier, tokens); - if (!tokens.empty()) { - // At least one setup token was presented; the session is authorized iff - // any of the verified ones grants ClientSetup. All verified tokens' other - // scopes still pool into the session grants below, not just the one that - // happened to grant ClientSetup. + if (verifier.requireSetupToken()) { + if (tokens.empty()) { + return folly::makeUnexpected(AuthError::Missing); + } + // Session is authorized iff any verified setup token grants ClientSetup; + // all verified tokens' scopes still pool into the session grants below, + // not just the one that granted it. if (!allowsAny(verifiedGrants, Action::ClientSetup, TrackNamespace{})) { - return folly::makeUnexpected(firstError.value_or(AuthError::Forbidden)); + return folly::makeUnexpected(denialError(verifiedGrants, firstError)); } - return std::make_shared>(std::move(verifiedGrants)); - } - - if (verifier.requireSetupToken()) { - return folly::makeUnexpected(AuthError::Missing); } - // No setup token but not required: connect with empty session grants; - // requests must then carry their own tokens to be authorized (see authorize()). - return std::make_shared>(); + return std::make_shared>(std::move(verifiedGrants)); } folly::Expected authorize( @@ -230,34 +270,43 @@ folly::Expected authorize( return folly::unit; } + auto tokens = findAuthTokens(params, verifier.tokenType()); + if (tokens.size() > verifier.maxTokensPerMessage()) { + XLOG(WARN) << "authorize: " << tokens.size() << " request tokens exceeds " + << "max_tokens_per_message=" << verifier.maxTokensPerMessage() + << " for action=" << static_cast(action) << "; rejecting"; + return folly::makeUnexpected(AuthError::TooManyTokens); + } + std::vector requestGrants; std::optional firstError; if (verifier.allowRequestTokenOverride()) { - for (const auto& token : findAuthTokens(params, verifier.tokenType())) { - auto res = verifier.verify(token); - if (res.hasError()) { - // Dropped as a non-viable candidate — another request token or a - // session grant may still cover the action. - if (!firstError) { - firstError = res.error(); - } - continue; - } - requestGrants.push_back(std::move(res.value())); - } - } else if (!findAuthTokens(params, verifier.tokenType()).empty()) { + auto verified = verifyTokens(verifier, tokens); + requestGrants = std::move(verified.grants); + firstError = verified.firstError; + } else if (!tokens.empty()) { XLOG(DBG1) << "authorize: ignoring request AUTHORIZATION_TOKEN(s) for action=" << static_cast(action) << " (allow_request_token_override is disabled)"; } - const bool permitted = - trackName ? (allowsAny(requestGrants, action, FullTrackName{ns, std::string(*trackName)}) || - allowsAny(sessionGrants, action, FullTrackName{ns, std::string(*trackName)})) - : (allowsAny(requestGrants, action, ns) || allowsAny(sessionGrants, action, ns)); + // Any one of three sources is sufficient. + const auto& anonymousGrants = verifier.anonymousGrants(); + const auto now = std::chrono::system_clock::now(); + bool permitted = false; + if (trackName) { + const FullTrackName ftn{ns, std::string(*trackName)}; + permitted = allowsAny(requestGrants, action, ftn, now) || + allowsAny(sessionGrants, action, ftn, now) || + allows(anonymousGrants, action, ftn, now); + } else { + permitted = allowsAny(requestGrants, action, ns, now) || + allowsAny(sessionGrants, action, ns, now) || + allows(anonymousGrants, action, ns, now); + } if (!permitted) { XLOG(DBG1) << "authorize: action=" << static_cast(action) << " not permitted for ns=" << ns; - return folly::makeUnexpected(firstError.value_or(AuthError::Forbidden)); + return folly::makeUnexpected(denialError(requestGrants, firstError)); } return folly::unit; } @@ -275,8 +324,8 @@ std::vector findAuthTokens(const Parameters& params, uint64_t tokenTy namespace { -// Shared implementation for both allows() overloads. A namespace-level check -// passes std::nullopt for trackName (the track match rules then see empty bytes). +// A namespace-level check passes std::nullopt for trackName (the track match +// rules then see empty bytes). bool allowsImpl( const Grants& grants, Action action, @@ -373,6 +422,8 @@ const char* toString(AuthError error) { return "expired authorization token"; case AuthError::Forbidden: return "authorization token does not permit action"; + case AuthError::TooManyTokens: + return "too many authorization tokens in message"; } return "authorization failed"; } diff --git a/src/auth/Auth.h b/src/auth/Auth.h index f6296db08..3152c70f6 100644 --- a/src/auth/Auth.h +++ b/src/auth/Auth.h @@ -6,6 +6,7 @@ #pragma once +#include "auth/Action.h" #include "config/Config.h" #include @@ -23,18 +24,6 @@ namespace openmoq::moqx::auth { -enum class Action : uint64_t { - ClientSetup = 0, - ServerSetup = 1, - PublishNamespace = 2, - SubscribeNamespace = 3, - Subscribe = 4, - RequestUpdate = 5, - Publish = 6, - Fetch = 7, - TrackStatus = 8, -}; - enum class AuthError { Missing, WrongTokenType, @@ -42,10 +31,11 @@ enum class AuthError { BadSignature, Expired, Forbidden, + TooManyTokens, }; struct MatchRule { - enum class Type : uint64_t { Exact = 0, Prefix = 1, Suffix = 2, Contains = 3 }; + using Type = MatchRuleType; Type type{Type::Exact}; std::string value; }; @@ -61,6 +51,21 @@ struct Grants { std::vector scopes; }; +// Length-prefixed byte-encoding of a namespace's segments, used both as a +// MatchRule value and as the bytes matched against it. A stable wire format: +// signer and verifier must produce byte-identical output independently. +inline std::string canonicalNamespace(const moxygen::TrackNamespace& ns) { + std::string out; + for (const auto& field : ns.trackNamespace) { + out.push_back(static_cast((field.size() >> 24) & 0xff)); + out.push_back(static_cast((field.size() >> 16) & 0xff)); + out.push_back(static_cast((field.size() >> 8) & 0xff)); + out.push_back(static_cast(field.size() & 0xff)); + out.append(field); + } + return out; +} + class AuthTokenVerifier { public: explicit AuthTokenVerifier(config::AuthConfig config); @@ -70,6 +75,18 @@ class AuthTokenVerifier { bool requireSetupToken() const { return config_.requireSetupToken; } bool allowRequestTokenOverride() const { return config_.allowRequestTokenOverride; } + // Caps how many AUTHORIZATION_TOKEN params of the configured tokenType() + // one message may carry; a message over the cap is rejected outright, not + // truncated. Params of other token types are not counted. 0 would reject + // every message carrying a matching token. Config validation requires >=1 + // when enabled(). + uint32_t maxTokensPerMessage() const { return config_.maxTokensPerMessage; } + + // Statically-configured anonymous claim, applied as a floor on every + // request (see docs/config.md#anonymous-claim). Never covers + // Action::ClientSetup — it can't bypass a required setup token. + const Grants& anonymousGrants() const { return anonymousGrants_; } + folly::Expected verify(const moxygen::AuthToken& token) const; private: @@ -85,6 +102,7 @@ class AuthTokenVerifier { config::AuthConfig config_; std::vector derivedKeys_; std::unordered_map keyIdIndex_; + Grants anonymousGrants_; }; // Returns every AUTHORIZATION_TOKEN parameter matching tokenType, not just the @@ -127,10 +145,11 @@ bool allowsAny( const char* toString(AuthError error); -// Verifies the setup AUTHORIZATION_TOKEN(s). Returns a null pointer when auth -// is disabled; otherwise a shared vector of every successfully-verified -// setup token's grants (possibly empty), gating the session on whether any -// of them permits Action::ClientSetup. +// Verifies the setup AUTHORIZATION_TOKEN(s): null when auth is disabled, +// else every successfully-verified token's grants. When requireSetupToken() +// is set, connecting is additionally gated on whether any one of them +// permits Action::ClientSetup; without it, verified grants pool into the +// session with no ClientSetup check. folly::Expected>, AuthError> authenticateSetup(const AuthTokenVerifier& verifier, const moxygen::Parameters& setupParams); diff --git a/src/auth/AuthTokenIssuer.cpp b/src/auth/AuthTokenIssuer.cpp index 27d0ff92f..a010429fb 100644 --- a/src/auth/AuthTokenIssuer.cpp +++ b/src/auth/AuthTokenIssuer.cpp @@ -6,6 +6,7 @@ #include "auth/AuthTokenIssuer.h" +#include "auth/Action.h" #include "auth/HmacKey.h" #include @@ -23,18 +24,6 @@ namespace openmoq::moqx::auth { namespace { -std::string canonicalNamespace(const moxygen::TrackNamespace& ns) { - std::string out; - for (const auto& field : ns.trackNamespace) { - out.push_back(static_cast((field.size() >> 24) & 0xff)); - out.push_back(static_cast((field.size() >> 16) & 0xff)); - out.push_back(static_cast((field.size() >> 8) & 0xff)); - out.push_back(static_cast(field.size() & 0xff)); - out.append(field); - } - return out; -} - catapult::MoqtCompoundMatch toCatapultMatch(const std::vector& rules) { if (rules.empty()) { return catapult::MoqtCompoundMatch::any(); @@ -98,43 +87,9 @@ std::string trim(std::string_view value) { return std::string(begin, end); } -std::string normalizeActionName(std::string_view value) { - auto out = trim(value); - std::replace(out.begin(), out.end(), '-', '_'); - std::transform(out.begin(), out.end(), out.begin(), [](unsigned char c) { - return static_cast(std::tolower(c)); - }); - return out; -} - Action parseAction(std::string_view value) { - const auto name = normalizeActionName(value); - if (name == "client_setup" || name == "setup" || name == "0") { - return Action::ClientSetup; - } - if (name == "server_setup" || name == "1") { - return Action::ServerSetup; - } - if (name == "publish_namespace" || name == "announce" || name == "2") { - return Action::PublishNamespace; - } - if (name == "subscribe_namespace" || name == "3") { - return Action::SubscribeNamespace; - } - if (name == "subscribe" || name == "4") { - return Action::Subscribe; - } - if (name == "request_update" || name == "subscribe_update" || name == "5") { - return Action::RequestUpdate; - } - if (name == "publish" || name == "6") { - return Action::Publish; - } - if (name == "fetch" || name == "7") { - return Action::Fetch; - } - if (name == "track_status" || name == "8") { - return Action::TrackStatus; + if (auto action = canonicalAction(value)) { + return *action; } throw std::invalid_argument("unknown CAT4MOQ action: " + std::string(value)); } diff --git a/src/config/Config.h b/src/config/Config.h index 4ddb41c33..f5d639ecc 100644 --- a/src/config/Config.h +++ b/src/config/Config.h @@ -6,6 +6,8 @@ #pragma once +#include "auth/Action.h" + #include #include #include @@ -188,12 +190,25 @@ struct AuthConfig { std::string secret; }; + // A statically-granted scope applied to every request, regardless of + // token. + struct AnonymousScope { + std::vector actions; + // nullopt matches any namespace. An empty vector matches only the zero-segment namespace. + std::optional> namespaceSegments; + auth::MatchRuleType namespaceMatchMode{auth::MatchRuleType::Exact}; + std::optional trackName; // nullopt = match any track + auth::MatchRuleType trackMatchMode{auth::MatchRuleType::Exact}; + }; + bool enabled{false}; uint64_t tokenType{0}; std::vector hmacKeys; bool requireSetupToken{true}; bool allowRequestTokenOverride{true}; bool strictClaims{false}; + std::vector anonymousClaim; // empty = no anonymous access (default) + uint32_t maxTokensPerMessage{0}; }; struct ServiceConfig { diff --git a/src/config/ConfigResolver.cpp b/src/config/ConfigResolver.cpp index ee274465f..94d66d8bb 100644 --- a/src/config/ConfigResolver.cpp +++ b/src/config/ConfigResolver.cpp @@ -20,6 +20,7 @@ #include +#include "auth/Action.h" #include "config/Pkcs12.h" namespace openmoq::moqx::config { @@ -535,36 +536,127 @@ void validateAuth( const ParsedAuthConfig& auth, std::vector& errors ) { - if (!auth.enabled.value()) { + if (auth.enabled.value()) { + const auto& keys = auth.hmac_keys.value(); + if (!keys.has_value() || keys->empty()) { + errors.push_back( + "Service '" + serviceName + "': auth.hmac_keys is required when auth is enabled" + ); + } else { + std::unordered_set keyIDs; + for (size_t i = 0; i < keys->size(); ++i) { + const auto& key = (*keys)[i]; + const auto prefix = + "Service '" + serviceName + "': auth.hmac_keys[" + std::to_string(i) + "]"; + if (key.id.value().empty()) { + errors.push_back(prefix + ".id must be non-empty"); + } else if (!keyIDs.insert(key.id.value()).second) { + errors.push_back(prefix + ".id duplicates another auth key"); + } + if (key.secret.value().empty()) { + errors.push_back(prefix + ".secret must be non-empty"); + } + } + } + // token_type 0 is a valid MOQT AUTHORIZATION_TOKEN type, accepted as-is + // and not treated as "unset". An omitted token_type also resolves to 0 + // (see resolveAuth): omitting it and setting token_type: 0 are identical. + if (auth.token_type.value().value_or(0) >= kQuicVarintExclusiveUpperBound) { + errors.push_back("Service '" + serviceName + "': auth.token_type must fit in a QUIC varint"); + } + const auto& maxTokens = auth.max_tokens_per_message.value(); + if (!maxTokens.has_value()) { + errors.push_back( + "Service '" + serviceName + + "': auth.max_tokens_per_message is required when auth is enabled (set it directly or " + "via service_defaults.auth)" + ); + } else if (*maxTokens == 0) { + errors.push_back("Service '" + serviceName + "': auth.max_tokens_per_message must be >= 1"); + } + } + + // Validated regardless of `enabled`: resolveAuth() resolves anonymous_claim + // unconditionally. A malformed entry must not sit un-diagnosed in the + // resolved config just because auth is off. + const auto& anonymousClaim = auth.anonymous_claim.value(); + if (!anonymousClaim.has_value()) { return; } - const auto& keys = auth.hmac_keys.value(); - if (!keys.has_value() || keys->empty()) { - errors.push_back( - "Service '" + serviceName + "': auth.hmac_keys is required when auth is enabled" - ); - } else { - std::unordered_set keyIDs; - for (size_t i = 0; i < keys->size(); ++i) { - const auto& key = (*keys)[i]; - const auto prefix = - "Service '" + serviceName + "': auth.hmac_keys[" + std::to_string(i) + "]"; - if (key.id.value().empty()) { - errors.push_back(prefix + ".id must be non-empty"); - } else if (!keyIDs.insert(key.id.value()).second) { - errors.push_back(prefix + ".id duplicates another auth key"); - } - if (key.secret.value().empty()) { - errors.push_back(prefix + ".secret must be non-empty"); + for (size_t i = 0; i < anonymousClaim->size(); ++i) { + const auto& scope = (*anonymousClaim)[i]; + const auto prefix = + "Service '" + serviceName + "': auth.anonymous_claim[" + std::to_string(i) + "]"; + const auto& actions = scope.actions.value(); + if (actions.empty()) { + errors.push_back(prefix + ".actions must be non-empty"); + continue; + } + for (const auto& name : actions) { + auto action = auth::canonicalAction(name); + if (!action) { + errors.push_back(prefix + ".actions: '" + name + "' is not a recognized CAT4MOQ action"); + } else if (*action == auth::Action::ClientSetup || *action == auth::Action::ServerSetup) { + errors.push_back( + prefix + ".actions: '" + name + + "' is not allowed here -- the anonymous claim never authorizes session setup" + ); } } } - // token_type 0 is a valid MOQT AUTHORIZATION_TOKEN type and is accepted as-is; - // it is not treated as "unset". An omitted token_type also resolves to 0 (see - // resolveAuth), so omitting it and setting token_type: 0 behave identically. - if (auth.token_type.value().value_or(0) >= kQuicVarintExclusiveUpperBound) { - errors.push_back("Service '" + serviceName + "': auth.token_type must fit in a QUIC varint"); +} + +AuthConfig::AnonymousScope resolveAnonymousScope(const ParsedAuthConfig::AnonymousScope& scope) { + using Parsed = ParsedAuthConfig::AnonymousScope; + using MatchMode = auth::MatchRuleType; + + AuthConfig::AnonymousScope out; + // Resolution runs only on validated input; every name canonicalizes. + for (const auto& name : scope.actions.value()) { + out.actions.push_back(*auth::canonicalAction(name)); + } + + const auto& nsMatch = scope.namespace_match.value(); + if (nsMatch.has_value()) { + nsMatch->visit([&](const auto& alt) { + using N = std::decay_t; + if constexpr (std::is_same_v) { + out.namespaceSegments = alt.exact.value(); + out.namespaceMatchMode = MatchMode::Exact; + } else if constexpr (std::is_same_v) { + out.namespaceSegments = alt.prefix.value(); + out.namespaceMatchMode = MatchMode::Prefix; + } else if constexpr (std::is_same_v) { + out.namespaceSegments = alt.suffix.value(); + out.namespaceMatchMode = MatchMode::Suffix; + } else { // ContainsNamespace + out.namespaceSegments = alt.contains.value(); + out.namespaceMatchMode = MatchMode::Contains; + } + }); + } + + const auto& trackMatch = scope.track_match.value(); + if (trackMatch.has_value()) { + trackMatch->visit([&](const auto& alt) { + using T = std::decay_t; + if constexpr (std::is_same_v) { + out.trackName = alt.exact.value(); + out.trackMatchMode = MatchMode::Exact; + } else if constexpr (std::is_same_v) { + out.trackName = alt.prefix.value(); + out.trackMatchMode = MatchMode::Prefix; + } else if constexpr (std::is_same_v) { + out.trackName = alt.suffix.value(); + out.trackMatchMode = MatchMode::Suffix; + } else { // ContainsTrack + out.trackName = alt.contains.value(); + out.trackMatchMode = MatchMode::Contains; + } + }); } + + return out; } AuthConfig resolveAuth(const std::optional& parsed) { @@ -577,6 +669,7 @@ AuthConfig resolveAuth(const std::optional& parsed) { out.requireSetupToken = parsed->require_setup_token.value().value_or(true); out.allowRequestTokenOverride = parsed->allow_request_token_override.value().value_or(true); out.strictClaims = parsed->strict_claims.value().value_or(false); + out.maxTokensPerMessage = parsed->max_tokens_per_message.value().value_or(0); if (parsed->hmac_keys.value()) { for (const auto& key : *parsed->hmac_keys.value()) { out.hmacKeys.push_back(AuthConfig::HmacKey{ @@ -585,9 +678,37 @@ AuthConfig resolveAuth(const std::optional& parsed) { }); } } + if (parsed->anonymous_claim.value()) { + for (const auto& scope : *parsed->anonymous_claim.value()) { + out.anonymousClaim.push_back(resolveAnonymousScope(scope)); + } + } return out; } +// Merges service_defaults.auth with service.auth into mergedAuths, then +// validates the result. No-op if the service sets no auth block of its own. +void validateServiceAuth( + const std::string& name, + const ParsedServiceConfig& svc, + const ParsedConfig& config, + std::unordered_map& mergedAuths, + std::vector& errors +) { + if (!svc.auth.value().has_value()) { + return; + } + auto mergedAuth = *svc.auth.value(); + if (!mergedAuth.max_tokens_per_message.value().has_value() && + config.service_defaults.value().has_value() && + config.service_defaults.value()->auth.value().has_value()) { + mergedAuth.max_tokens_per_message = + config.service_defaults.value()->auth.value()->max_tokens_per_message.value(); + } + validateAuth(name, mergedAuth, errors); + mergedAuths.emplace(name, std::move(mergedAuth)); +} + // --- Service validation --- void validateService( @@ -596,6 +717,7 @@ void validateService( const ParsedConfig& config, std::unordered_set& compositeKeys, std::unordered_map& mergedCaches, + std::unordered_map& mergedAuths, std::vector& errors ) { @@ -668,9 +790,7 @@ void validateService( if (svc.upstream.value().has_value()) { validateUpstream(*svc.upstream.value(), errors); } - if (svc.auth.value().has_value()) { - validateAuth(name, *svc.auth.value(), errors); - } + validateServiceAuth(name, svc, config, mergedAuths, errors); } std::string generateRelayID() { @@ -1002,7 +1122,11 @@ ServiceConfig::MatchEntry resolveMatchEntry(const ParsedServiceConfig::MatchRule }; } -ServiceConfig resolveService(const ParsedServiceConfig& svc, const ParsedCacheConfig& cache) { +ServiceConfig resolveService( + const ParsedServiceConfig& svc, + const ParsedCacheConfig& cache, + const std::optional& auth +) { std::vector entries; for (const auto& entry : svc.match.value()) { entries.push_back(resolveMatchEntry(entry)); @@ -1015,7 +1139,7 @@ ServiceConfig resolveService(const ParsedServiceConfig& svc, const ParsedCacheCo .match = std::move(entries), .cache = resolveCacheConfig(cache), .upstream = std::move(upstream), - .auth = resolveAuth(svc.auth.value()), + .auth = resolveAuth(auth), }; } @@ -1129,9 +1253,10 @@ folly::Expected resolveConfig(const ParsedConfig& c std::unordered_set compositeKeys; std::unordered_map mergedCaches; + std::unordered_map mergedAuths; for (const auto& [name, svc] : services) { - validateService(name, svc, config, compositeKeys, mergedCaches, errors); + validateService(name, svc, config, compositeKeys, mergedCaches, mergedAuths, errors); } // === Validate threads === @@ -1176,7 +1301,11 @@ folly::Expected resolveConfig(const ParsedConfig& c folly::F14FastMap resolvedServices; for (const auto& [name, svc] : services) { - resolvedServices.emplace(name, resolveService(svc, mergedCaches.at(name))); + auto authIt = mergedAuths.find(name); + std::optional auth = authIt != mergedAuths.end() + ? std::optional(authIt->second) + : std::nullopt; + resolvedServices.emplace(name, resolveService(svc, mergedCaches.at(name), auth)); } // Resolve admin config diff --git a/src/config/ConfigSerializer.h b/src/config/ConfigSerializer.h index fea889b33..45f97826e 100644 --- a/src/config/ConfigSerializer.h +++ b/src/config/ConfigSerializer.h @@ -10,6 +10,7 @@ #include #include +#include "auth/Action.h" #include "config/Config.h" // Format-agnostic walk over a resolved Config. The walker lives next to the @@ -55,6 +56,20 @@ inline std::string_view quicStackName(QuicStack s) { return "unknown"; } +inline std::string_view matchRuleTypeName(auth::MatchRuleType m) { + switch (m) { + case auth::MatchRuleType::Exact: + return "exact"; + case auth::MatchRuleType::Prefix: + return "prefix"; + case auth::MatchRuleType::Suffix: + return "suffix"; + case auth::MatchRuleType::Contains: + return "contains"; + } + return "unknown"; +} + inline void serializeTls(ConfigSink& s, const TlsConfig& tls) { s.boolField("insecure", false); s.stringField("cert_file", tls.certFile); @@ -190,6 +205,7 @@ inline void serializeAuth(ConfigSink& s, const AuthConfig& a) { s.boolField("require_setup_token", a.requireSetupToken); s.boolField("allow_request_token_override", a.allowRequestTokenOverride); s.boolField("strict_claims", a.strictClaims); + s.uintField("max_tokens_per_message", a.maxTokensPerMessage); s.beginArray("hmac_keys"); for (const auto& k : a.hmacKeys) { s.beginObject(""); @@ -200,6 +216,36 @@ inline void serializeAuth(ConfigSink& s, const AuthConfig& a) { s.endObject(); } s.endArray(); + s.beginArray("anonymous_claim"); + for (const auto& scope : a.anonymousClaim) { + s.beginObject(""); + s.beginArray("actions"); + for (const auto& action : scope.actions) { + s.stringField("", auth::actionName(action)); + } + s.endArray(); + // null (match any namespace) must stay distinguishable from an explicit + // empty list, which matches only the zero-segment namespace (see + // Config.h's AnonymousScope). + if (scope.namespaceSegments) { + s.beginArray("namespace_segments"); + for (const auto& seg : *scope.namespaceSegments) { + s.stringField("", seg); + } + s.endArray(); + } else { + s.nullField("namespace_segments"); + } + s.stringField("namespace_match_mode", matchRuleTypeName(scope.namespaceMatchMode)); + if (scope.trackName) { + s.stringField("track_name", *scope.trackName); + } else { + s.nullField("track_name"); + } + s.stringField("track_match_mode", matchRuleTypeName(scope.trackMatchMode)); + s.endObject(); + } + s.endArray(); s.endObject(); } diff --git a/src/config/loader/ParsedConfig.h b/src/config/loader/ParsedConfig.h index e66efcaef..d9fa243bc 100644 --- a/src/config/loader/ParsedConfig.h +++ b/src/config/loader/ParsedConfig.h @@ -362,6 +362,58 @@ struct ParsedAuthConfig { rfl::Description<"Shared HMAC secret for this key id", std::string> secret; }; + // A statically-granted scope applied to every request on this service, + // regardless of what token (if any) authenticated it (see docs/config.md). + struct AnonymousScope { + struct ExactNamespace { + rfl::Description<"Match this exact ordered namespace segment list", std::vector> + exact; + }; + struct PrefixNamespace { + rfl::Description< + "Match namespaces whose leading segments equal this list", + std::vector> + prefix; + }; + struct SuffixNamespace { + rfl::Description< + "Match namespaces whose trailing segments equal this list", + std::vector> + suffix; + }; + struct ContainsNamespace { + rfl::Description< + "Match namespaces containing this segment list as a contiguous run", + std::vector> + contains; + }; + using NamespaceMatch = + rfl::Variant; + + struct ExactTrack { + rfl::Description<"Match this exact track name", std::string> exact; + }; + struct PrefixTrack { + rfl::Description<"Match track names starting with this value", std::string> prefix; + }; + struct SuffixTrack { + rfl::Description<"Match track names ending with this value", std::string> suffix; + }; + struct ContainsTrack { + rfl::Description<"Match track names containing this value", std::string> contains; + }; + using TrackMatch = rfl::Variant; + + rfl::Description< + "CAT4MOQ action names this scope grants without any token; client_setup/server_setup are " + "rejected -- the anonymous claim never authorizes session setup", + std::vector> + actions; + rfl::Description<"Namespace match; omit to match any namespace", std::optional> + namespace_match; + rfl::Description<"Track match; omit to match any track", std::optional> track_match; + }; + rfl::Description<"Enable per-service authorization", bool> enabled; rfl::Description<"Expected MOQT AUTHORIZATION_TOKEN token type", std::optional> token_type; @@ -378,6 +430,17 @@ struct ParsedAuthConfig { "Reject unsupported token claims instead of ignoring them (default: false)", std::optional> strict_claims; + rfl::Description< + "Anonymous (no-token) access grants applied to every request on this service, in " + "addition to any verified setup/request token grants (default: none)", + std::optional>> + anonymous_claim; + rfl::Description< + "Maximum AUTHORIZATION_TOKEN parameters allowed per SETUP or per request message; " + "a message over the cap is rejected outright. Required (directly or via " + "service_defaults.auth) when enabled: true -- bounds per-message verification cost.", + std::optional> + max_tokens_per_message; }; struct ParsedServiceConfig { @@ -442,6 +505,16 @@ struct ParsedListenerDefaultsConfig { struct ParsedServiceDefaultsConfig { rfl::Description<"Default cache settings for services", std::optional> cache; + + // Only max_tokens_per_message is inheritable; auth fields absent here + // (keys, claims, flags) are per-service only. + struct AuthDefaults { + rfl::Description< + "Default auth.max_tokens_per_message for services that don't set their own", + std::optional> + max_tokens_per_message; + }; + rfl::Description<"Default auth settings for services", std::optional> auth; }; struct ParsedMLogConfig { diff --git a/test/AuthTest.cpp b/test/AuthTest.cpp index b693d6d9d..065264744 100644 --- a/test/AuthTest.cpp +++ b/test/AuthTest.cpp @@ -34,6 +34,7 @@ config::AuthConfig makeConfig() { .hmacKeys = {config::AuthConfig::HmacKey{.id = "k1", .secret = "secret"}}, .requireSetupToken = true, .allowRequestTokenOverride = true, + .maxTokensPerMessage = 4, }; } @@ -61,6 +62,18 @@ makeToken(Grants grants, std::string_view secret = "secret", std::string_view ke }; } +config::AuthConfig::AnonymousScope makeAnonymousConfigScope( + std::vector actions, + std::optional> namespaceSegments = std::nullopt, + std::optional trackName = std::nullopt +) { + return config::AuthConfig::AnonymousScope{ + .actions = std::move(actions), + .namespaceSegments = std::move(namespaceSegments), + .trackName = std::move(trackName), + }; +} + } // namespace TEST(AuthTest, VerifiesSignedTokenAndAllowsMatchingAction) { @@ -379,6 +392,35 @@ TEST(AuthTest, AuthorizeReturnsMostSpecificErrorWhenNothingPermits) { EXPECT_EQ(result.error(), AuthError::BadSignature); } +// Regression: a corrupted request token must not eclipse a sibling token that +// verified fine but simply lacked scope -- the latter makes Forbidden the +// correct error, not the former's BadSignature. +TEST(AuthTest, AuthorizePrefersForbiddenOverBadSignature) { + TrackNamespace ns{{"live"}}; + AuthTokenVerifier verifier(makeConfig()); + Parameters params(FrameType::SUBSCRIBE); + auto badToken = makeToken(makeGrants({Action::Subscribe}, {}, {})); + badToken.tokenValue.back() ^= 0x01; // corrupt signature + ASSERT_TRUE( + params + .insertParam( + Parameter(static_cast(TrackRequestParamKey::AUTHORIZATION_TOKEN), badToken) + ) + .hasValue() + ); + ASSERT_TRUE(params + .insertParam(Parameter( + static_cast(TrackRequestParamKey::AUTHORIZATION_TOKEN), + makeToken(makeGrants({Action::Publish}, {}, {})) // verifies, but wrong action + )) + .hasValue()); + std::vector sessionGrants; // doesn't cover the action either + + auto result = authorize(verifier, Action::Subscribe, params, ns, sessionGrants, "video"); + ASSERT_TRUE(result.hasError()); + EXPECT_EQ(result.error(), AuthError::Forbidden); +} + TEST(AuthTest, AuthorizeReturnsForbiddenWhenNothingCoversAction) { TrackNamespace ns{{"live"}}; AuthTokenVerifier verifier(makeConfig()); @@ -457,6 +499,36 @@ TEST(AuthTest, AuthenticateSetupRejectsWhenNoTokenGrantsClientSetup) { EXPECT_EQ(result.error(), AuthError::Forbidden); } +// Regression: a corrupted setup token must not eclipse a sibling token that +// verified fine but simply didn't grant ClientSetup -- the latter makes +// Forbidden the correct error, not the former's BadSignature. +TEST(AuthTest, AuthenticateSetupPrefersForbiddenOverBadSignature) { + AuthTokenVerifier verifier(makeConfig()); + auto badToken = makeToken(makeGrants({Action::ClientSetup}, {}, {})); + badToken.tokenValue.back() ^= 0x01; // corrupt signature + + Parameters params(FrameType::CLIENT_SETUP); + ASSERT_TRUE( + params + .insertParam( + Parameter(static_cast(TrackRequestParamKey::AUTHORIZATION_TOKEN), badToken) + ) + .hasValue() + ); + ASSERT_TRUE( + params + .insertParam(Parameter( + static_cast(TrackRequestParamKey::AUTHORIZATION_TOKEN), + makeToken(makeGrants({Action::Subscribe}, {}, {})) // verifies, but not ClientSetup + )) + .hasValue() + ); + + auto result = authenticateSetup(verifier, params); + ASSERT_TRUE(result.hasError()); + EXPECT_EQ(result.error(), AuthError::Forbidden); +} + TEST(AuthTest, AuthenticateSetupSurfacesMostSpecificErrorWhenNothingGrantsClientSetup) { AuthTokenVerifier verifier(makeConfig()); auto badToken = makeToken(makeGrants({Action::ClientSetup}, {}, {})); @@ -475,3 +547,237 @@ TEST(AuthTest, AuthenticateSetupSurfacesMostSpecificErrorWhenNothingGrantsClient ASSERT_TRUE(result.hasError()); EXPECT_EQ(result.error(), AuthError::BadSignature); } + +// Regression: require_setup_token=false must never fail the connection over +// the setup token, even when one is presented and doesn't grant ClientSetup. +TEST(AuthTest, AuthenticateSetupNotRequiredPoolsGrantsWithoutClientSetup) { + auto config = makeConfig(); + config.requireSetupToken = false; + AuthTokenVerifier verifier(config); + + Parameters params(FrameType::CLIENT_SETUP); + ASSERT_TRUE( + params + .insertParam(Parameter( + static_cast(TrackRequestParamKey::AUTHORIZATION_TOKEN), + makeToken(makeGrants({Action::Subscribe}, {}, {})) // verifies, but not ClientSetup + )) + .hasValue() + ); + + auto result = authenticateSetup(verifier, params); + ASSERT_TRUE(result.hasValue()); + ASSERT_TRUE(result.value()); + ASSERT_EQ(result.value()->size(), 1u); + EXPECT_TRUE(allowsAny(*result.value(), Action::Subscribe, TrackNamespace{})); +} + +// Regression: same as above, but the only presented token fails to verify +// outright -- connecting still must not fail, just with no pooled grants. +TEST(AuthTest, AuthenticateSetupNotRequiredConnectsDespiteFailedToken) { + auto config = makeConfig(); + config.requireSetupToken = false; + AuthTokenVerifier verifier(config); + + auto badToken = makeToken(makeGrants({Action::ClientSetup}, {}, {})); + badToken.tokenValue.back() ^= 0x01; // corrupt signature + + Parameters params(FrameType::CLIENT_SETUP); + ASSERT_TRUE( + params + .insertParam( + Parameter(static_cast(TrackRequestParamKey::AUTHORIZATION_TOKEN), badToken) + ) + .hasValue() + ); + + auto result = authenticateSetup(verifier, params); + ASSERT_TRUE(result.hasValue()); + ASSERT_TRUE(result.value()); + EXPECT_TRUE(result.value()->empty()); +} + +// --- max_tokens_per_message: caps per-message verification cost --- + +// Regression: a message over the cap is rejected outright, not truncated to +// the first N tokens -- a covering token past the cap must not silently lose +// to a non-covering one that happened to arrive first. +TEST(AuthTest, AuthorizeRejectsRequestTokensBeyondMaxTokensPerMessage) { + TrackNamespace ns{{"live"}}; + auto config = makeConfig(); + config.maxTokensPerMessage = 1; + AuthTokenVerifier verifier(config); + + Parameters params(FrameType::SUBSCRIBE); + ASSERT_TRUE(params + .insertParam(Parameter( + static_cast(TrackRequestParamKey::AUTHORIZATION_TOKEN), + makeToken(makeGrants({Action::Publish}, {}, {})) // 1st token: wrong action + )) + .hasValue()); + ASSERT_TRUE( + params + .insertParam(Parameter( + static_cast(TrackRequestParamKey::AUTHORIZATION_TOKEN), + makeToken(makeGrants({Action::Subscribe}, {}, {})) // 2nd token: would grant it + )) + .hasValue() + ); + std::vector sessionGrants; // doesn't cover the action either + + // 2 tokens > cap of 1: the whole message is rejected, regardless of what + // either token would have granted. + auto result = authorize(verifier, Action::Subscribe, params, ns, sessionGrants, "video"); + ASSERT_TRUE(result.hasError()); + EXPECT_EQ(result.error(), AuthError::TooManyTokens); +} + +// Regression: disabled allow_request_token_override must not exempt a +// message from the cap -- the cap is checked before request tokens are +// considered at all, so an anonymous_claim that would otherwise permit the +// action can't rescue an over-cap message. +TEST(AuthTest, AuthorizeEnforcesTokenCapWhenOverrideDisabled) { + TrackNamespace ns{{"live"}}; + auto config = makeConfig(); + config.allowRequestTokenOverride = false; + config.maxTokensPerMessage = 1; + config.anonymousClaim = {makeAnonymousConfigScope({Action::Subscribe})}; + AuthTokenVerifier verifier(config); + + Parameters params(FrameType::SUBSCRIBE); + ASSERT_TRUE(params + .insertParam(Parameter( + static_cast(TrackRequestParamKey::AUTHORIZATION_TOKEN), + makeToken(makeGrants({Action::Publish}, {}, {})) + )) + .hasValue()); + ASSERT_TRUE(params + .insertParam(Parameter( + static_cast(TrackRequestParamKey::AUTHORIZATION_TOKEN), + makeToken(makeGrants({Action::Subscribe}, {}, {})) + )) + .hasValue()); + std::vector sessionGrants; + + auto result = authorize(verifier, Action::Subscribe, params, ns, sessionGrants, "video"); + ASSERT_TRUE(result.hasError()); + EXPECT_EQ(result.error(), AuthError::TooManyTokens); +} + +TEST(AuthTest, AuthenticateSetupRejectsSetupTokensBeyondMaxTokensPerMessage) { + auto config = makeConfig(); + config.maxTokensPerMessage = 1; + AuthTokenVerifier verifier(config); + + Parameters params(FrameType::CLIENT_SETUP); + ASSERT_TRUE( + params + .insertParam(Parameter( + static_cast(TrackRequestParamKey::AUTHORIZATION_TOKEN), + makeToken(makeGrants({Action::Subscribe}, {}, {})) // 1st token: not ClientSetup + )) + .hasValue() + ); + ASSERT_TRUE( + params + .insertParam(Parameter( + static_cast(TrackRequestParamKey::AUTHORIZATION_TOKEN), + makeToken(makeGrants({Action::ClientSetup}, {}, {})) // 2nd token: would grant it + )) + .hasValue() + ); + + // 2 tokens > cap of 1: the whole message is rejected, regardless of what + // either token would have granted. + auto result = authenticateSetup(verifier, params); + ASSERT_TRUE(result.hasError()); + EXPECT_EQ(result.error(), AuthError::TooManyTokens); +} + +// --- Anonymous claim: a static, config-driven floor --- + +TEST(AuthTest, AuthorizeAnonymousClaimCoversWhatNeitherSessionNorRequestTokenCover) { + TrackNamespace ns{{"live"}}; + auto config = makeConfig(); + config.anonymousClaim = {makeAnonymousConfigScope({Action::Fetch})}; + AuthTokenVerifier verifier(config); + + Parameters params(FrameType::FETCH); // no request token + std::vector sessionGrants{makeGrants({Action::Subscribe}, {}, {})}; // doesn't cover Fetch + + auto result = authorize(verifier, Action::Fetch, params, ns, sessionGrants, "video"); + EXPECT_TRUE(result.hasValue()); +} + +TEST(AuthTest, AuthorizeAnonymousClaimDoesNotCoverActionsOutsideItsScope) { + TrackNamespace ns{{"live"}}; + auto config = makeConfig(); + config.anonymousClaim = {makeAnonymousConfigScope({Action::Fetch})}; + AuthTokenVerifier verifier(config); + + Parameters params(FrameType::PUBLISH); // no request token + std::vector sessionGrants; // empty + + auto result = authorize(verifier, Action::Publish, params, ns, sessionGrants, "video"); + ASSERT_TRUE(result.hasError()); + EXPECT_EQ(result.error(), AuthError::Forbidden); +} + +// The anonymous claim never satisfies the ClientSetup gate, even if +// (hypothetically, bypassing config validation) it were configured to grant +// it -- authenticateSetup() never consults anonymousGrants() at all. +TEST(AuthTest, AuthenticateSetupAnonymousClaimNeverSatisfiesClientSetupGate) { + auto config = makeConfig(); + config.anonymousClaim = {makeAnonymousConfigScope({Action::ClientSetup})}; + AuthTokenVerifier verifier(config); + + Parameters params(FrameType::CLIENT_SETUP); // no setup token at all + auto result = authenticateSetup(verifier, params); + ASSERT_TRUE(result.hasError()); + EXPECT_EQ(result.error(), AuthError::Missing); +} + +TEST(AuthTest, AuthorizeAnonymousClaimNamespaceMatchRestrictsToConfiguredPrefix) { + auto config = makeConfig(); + config.anonymousClaim = {config::AuthConfig::AnonymousScope{ + .actions = {Action::Fetch}, + .namespaceSegments = std::vector{"live"}, + .namespaceMatchMode = MatchRuleType::Prefix, + }}; + AuthTokenVerifier verifier(config); + + Parameters params(FrameType::FETCH); // no request token + std::vector sessionGrants; // empty + + auto liveResult = authorize( + verifier, + Action::Fetch, + params, + TrackNamespace{{"live", "event1"}}, + sessionGrants, + "video" + ); + EXPECT_TRUE(liveResult.hasValue()); + + auto otherResult = + authorize(verifier, Action::Fetch, params, TrackNamespace{{"vod"}}, sessionGrants, "video"); + ASSERT_TRUE(otherResult.hasError()); + EXPECT_EQ(otherResult.error(), AuthError::Forbidden); +} + +// Regression: an explicitly-configured empty namespace segment list (e.g. +// `namespace_match: {exact: []}`) must restrict to the zero-segment +// namespace, not silently behave like "no namespace_match configured". +TEST(AuthTest, AuthorizeAnonymousClaimEmptyNamespaceListIsRestrictive) { + auto config = makeConfig(); + config.anonymousClaim = {makeAnonymousConfigScope({Action::Fetch}, std::vector{})}; + AuthTokenVerifier verifier(config); + + Parameters params(FrameType::FETCH); // no request token + std::vector sessionGrants; // empty + + auto result = + authorize(verifier, Action::Fetch, params, TrackNamespace{{"live"}}, sessionGrants, "video"); + ASSERT_TRUE(result.hasError()); + EXPECT_EQ(result.error(), AuthError::Forbidden); +} diff --git a/test/MoqxRelayContextTest.cpp b/test/MoqxRelayContextTest.cpp index 330f1172e..e5f129aeb 100644 --- a/test/MoqxRelayContextTest.cpp +++ b/test/MoqxRelayContextTest.cpp @@ -30,6 +30,12 @@ config::ServiceConfig makeService(std::string authority) { }; } +config::ServiceConfig makeServiceWithAuth(std::string authority, config::AuthConfig auth) { + auto svc = makeService(std::move(authority)); + svc.auth = std::move(auth); + return svc; +} + // Build a MockMoQSession with the given authority and path. // Uses a shared executor so the session doesn't spin up its own thread. std::shared_ptr> @@ -149,4 +155,58 @@ TEST_F(MoqxRelayContextTest, ValidateAuthority_PathRouting) { EXPECT_EQ(miss.error(), SessionCloseErrorCode::INVALID_AUTHORITY); } +// --- validateAuthority: anonymous claim --- + +TEST_F( + MoqxRelayContextTest, + ValidateAuthority_AnonymousClaimAllowsConnectWithoutSetupTokenWhenNotRequired +) { + folly::F14FastMap services = { + {"svc", + makeServiceWithAuth( + "live.example.com", + config::AuthConfig{ + .enabled = true, + .tokenType = 77, + .hmacKeys = {config::AuthConfig::HmacKey{.id = "k1", .secret = "supersecretvalue"}}, + .requireSetupToken = false, + .anonymousClaim = + {config::AuthConfig::AnonymousScope{.actions = {auth::Action::Subscribe}}}, + .maxTokensPerMessage = 4, + } + )}, + }; + MoqxRelayContext ctx(services, "test-relay"); + + auto session = makeSession(exec_, "live.example.com"); + auto result = ctx.validateAuthority(emptySetup_, anyVersion_, session); + + EXPECT_TRUE(result.hasValue()); +} + +TEST_F(MoqxRelayContextTest, ValidateAuthority_AnonymousClaimDoesNotBypassRequiredSetupToken) { + folly::F14FastMap services = { + {"svc", + makeServiceWithAuth( + "live.example.com", + config::AuthConfig{ + .enabled = true, + .tokenType = 77, + .hmacKeys = {config::AuthConfig::HmacKey{.id = "k1", .secret = "supersecretvalue"}}, + .requireSetupToken = true, + .anonymousClaim = + {config::AuthConfig::AnonymousScope{.actions = {auth::Action::Subscribe}}}, + .maxTokensPerMessage = 4, + } + )}, + }; + MoqxRelayContext ctx(services, "test-relay"); + + auto session = makeSession(exec_, "live.example.com"); + auto result = ctx.validateAuthority(emptySetup_, anyVersion_, session); + + ASSERT_FALSE(result.hasValue()); + EXPECT_EQ(result.error(), SessionCloseErrorCode::UNAUTHORIZED); +} + } // namespace diff --git a/test/config/ConfigResolverTest.cpp b/test/config/ConfigResolverTest.cpp index 2b722a942..63e4d25b8 100644 --- a/test/config/ConfigResolverTest.cpp +++ b/test/config/ConfigResolverTest.cpp @@ -7,6 +7,7 @@ #include "config/loader/ConfigResolver.h" #include "Pkcs12TestUtils.h" +#include "auth/Action.h" #include #include @@ -16,6 +17,7 @@ namespace openmoq::moqx::config { namespace { +using ::testing::ElementsAre; using ::testing::HasSubstr; using ::testing::IsEmpty; @@ -80,6 +82,7 @@ ParsedAuthConfig makeAuthConfig(std::vector keys = {m auth.require_setup_token = std::optional{false}; auth.allow_request_token_override = std::optional{false}; auth.strict_claims = std::optional{true}; + auth.max_tokens_per_message = std::optional{4}; return auth; } @@ -89,6 +92,32 @@ ParsedServiceConfig makeAuthService(ParsedAuthConfig auth = makeAuthConfig()) { return svc; } +using AnonymousScope = ParsedAuthConfig::AnonymousScope; + +AnonymousScope::NamespaceMatch prefixNamespaceMatch(std::vector segments) { + AnonymousScope::PrefixNamespace alt; + alt.prefix = std::move(segments); + return AnonymousScope::NamespaceMatch{std::move(alt)}; +} + +AnonymousScope::TrackMatch exactTrackMatch(std::string name) { + AnonymousScope::ExactTrack alt; + alt.exact = std::move(name); + return AnonymousScope::TrackMatch{std::move(alt)}; +} + +AnonymousScope makeAnonymousScope( + std::vector actions, + std::optional nsMatch = std::nullopt, + std::optional trackMatch = std::nullopt +) { + AnonymousScope scope; + scope.actions = std::move(actions); + scope.namespace_match = std::move(nsMatch); + scope.track_match = std::move(trackMatch); + return scope; +} + // Build a minimal valid insecure config with one any-authority service and admin. ParsedConfig makeMinimalInsecureConfig(std::string name = "test") { ParsedConfig cfg; @@ -739,6 +768,7 @@ TEST(ResolveConfig, AuthValidConfigRoundTripsAllFields) { EXPECT_FALSE(auth.requireSetupToken); EXPECT_FALSE(auth.allowRequestTokenOverride); EXPECT_TRUE(auth.strictClaims); + EXPECT_EQ(auth.maxTokensPerMessage, 4u); ASSERT_EQ(auth.hmacKeys.size(), 2); EXPECT_EQ(auth.hmacKeys[0].id, "key-1"); EXPECT_EQ(auth.hmacKeys[0].secret, "secret-1"); @@ -753,6 +783,7 @@ TEST(ResolveConfig, AuthOptionalFieldsDefaultCorrectly) { auth.enabled = true; std::vector keys{makeAuthKey()}; auth.hmac_keys = std::move(keys); + auth.max_tokens_per_message = std::optional{4}; cfg.services.value().emplace("svc", makeAuthService(std::move(auth))); auto result = resolveConfig(cfg); @@ -773,6 +804,187 @@ TEST(ResolveConfig, AuthAbsentDefaultsDisabled) { EXPECT_FALSE(result.value().config.services.at("default").auth.enabled); } +TEST(ResolveConfig, AuthMaxTokensPerMessageRequiredWhenEnabled) { + auto cfg = makeMinimalInsecureConfig(); + cfg.services.value().clear(); + ParsedAuthConfig auth; + auth.enabled = true; + auth.hmac_keys = std::optional>{{makeAuthKey()}}; + cfg.services.value().emplace("svc", makeAuthService(std::move(auth))); + + auto result = resolveConfig(cfg); + ASSERT_TRUE(result.hasError()); + EXPECT_THAT(result.error(), HasSubstr("auth.max_tokens_per_message is required")); +} + +TEST(ResolveConfig, AuthMaxTokensPerMessageRejectsZero) { + auto cfg = makeMinimalInsecureConfig(); + cfg.services.value().clear(); + auto auth = makeAuthConfig(); + auth.max_tokens_per_message = std::optional{0}; + cfg.services.value().emplace("svc", makeAuthService(std::move(auth))); + + auto result = resolveConfig(cfg); + ASSERT_TRUE(result.hasError()); + EXPECT_THAT(result.error(), HasSubstr("auth.max_tokens_per_message must be >= 1")); +} + +TEST(ResolveConfig, AuthMaxTokensPerMessageInheritsServiceDefaults) { + auto cfg = makeMinimalInsecureConfig(); + cfg.services.value().clear(); + auto auth = makeAuthConfig(); + auth.max_tokens_per_message = std::nullopt; // not set directly; must come from service_defaults + cfg.services.value().emplace("svc", makeAuthService(std::move(auth))); + + ParsedServiceDefaultsConfig::AuthDefaults authDefaults; + authDefaults.max_tokens_per_message = std::optional{6}; + ParsedServiceDefaultsConfig defaults; + defaults.auth = std::optional{authDefaults}; + cfg.service_defaults.value() = std::optional{defaults}; + + auto result = resolveConfig(cfg); + ASSERT_TRUE(result.hasValue()); + EXPECT_EQ(result.value().config.services.at("svc").auth.maxTokensPerMessage, 6u); +} + +TEST(ResolveConfig, AuthMaxTokensPerMessageServiceOverridesServiceDefaults) { + auto cfg = makeMinimalInsecureConfig(); + cfg.services.value().clear(); + auto auth = makeAuthConfig(); + auth.max_tokens_per_message = std::optional{10}; // set directly; wins over the default + cfg.services.value().emplace("svc", makeAuthService(std::move(auth))); + + ParsedServiceDefaultsConfig::AuthDefaults authDefaults; + authDefaults.max_tokens_per_message = std::optional{6}; + ParsedServiceDefaultsConfig defaults; + defaults.auth = std::optional{authDefaults}; + cfg.service_defaults.value() = std::optional{defaults}; + + auto result = resolveConfig(cfg); + ASSERT_TRUE(result.hasValue()); + EXPECT_EQ(result.value().config.services.at("svc").auth.maxTokensPerMessage, 10u); +} + +TEST(ResolveConfig, AuthAnonymousClaimRoundTripsAllFields) { + auto cfg = makeMinimalInsecureConfig(); + cfg.services.value().clear(); + + auto auth = makeAuthConfig(); + auth.anonymous_claim = + std::optional>{std::vector{makeAnonymousScope( + {"subscribe", "fetch"}, + prefixNamespaceMatch({"live"}), + exactTrackMatch("video") + )}}; + cfg.services.value().emplace("svc", makeAuthService(std::move(auth))); + + auto result = resolveConfig(cfg); + ASSERT_TRUE(result.hasValue()); + const auto& anon = result.value().config.services.at("svc").auth.anonymousClaim; + ASSERT_EQ(anon.size(), 1u); + EXPECT_THAT(anon[0].actions, ElementsAre(auth::Action::Subscribe, auth::Action::Fetch)); + ASSERT_TRUE(anon[0].namespaceSegments.has_value()); + EXPECT_THAT(*anon[0].namespaceSegments, ElementsAre("live")); + EXPECT_EQ(anon[0].namespaceMatchMode, auth::MatchRuleType::Prefix); + ASSERT_TRUE(anon[0].trackName.has_value()); + EXPECT_EQ(*anon[0].trackName, "video"); + EXPECT_EQ(anon[0].trackMatchMode, auth::MatchRuleType::Exact); +} + +TEST(ResolveConfig, AuthAnonymousClaimAbsentResolvesToEmpty) { + auto cfg = makeMinimalInsecureConfig(); + cfg.services.value().clear(); + cfg.services.value().emplace("svc", makeAuthService()); + + auto result = resolveConfig(cfg); + ASSERT_TRUE(result.hasValue()); + EXPECT_THAT(result.value().config.services.at("svc").auth.anonymousClaim, IsEmpty()); +} + +TEST(ResolveConfig, AuthAnonymousClaimOmittedNamespaceAndTrackMatchAnyNamespace) { + auto cfg = makeMinimalInsecureConfig(); + cfg.services.value().clear(); + + auto auth = makeAuthConfig(); + auth.anonymous_claim = std::optional>{ + std::vector{makeAnonymousScope({"subscribe"})} + }; + cfg.services.value().emplace("svc", makeAuthService(std::move(auth))); + + auto result = resolveConfig(cfg); + ASSERT_TRUE(result.hasValue()); + const auto& anon = result.value().config.services.at("svc").auth.anonymousClaim; + ASSERT_EQ(anon.size(), 1u); + EXPECT_FALSE(anon[0].namespaceSegments.has_value()); + EXPECT_FALSE(anon[0].trackName.has_value()); +} + +TEST(ResolveConfig, AuthAnonymousClaimRejectsUnknownAction) { + auto cfg = makeMinimalInsecureConfig(); + cfg.services.value().clear(); + + auto auth = makeAuthConfig(); + auth.anonymous_claim = std::optional>{ + std::vector{makeAnonymousScope({"frobnicate"})} + }; + cfg.services.value().emplace("svc", makeAuthService(std::move(auth))); + + auto result = resolveConfig(cfg); + ASSERT_TRUE(result.hasError()); + EXPECT_THAT(result.error(), HasSubstr("is not a recognized CAT4MOQ action")); +} + +// Regression: anonymous_claim must be validated even when auth.enabled is +// false. resolveAuth() resolves it into the config unconditionally. +TEST(ResolveConfig, AuthAnonymousClaimValidatedEvenWhenAuthDisabled) { + auto cfg = makeMinimalInsecureConfig(); + cfg.services.value().clear(); + + auto auth = makeAuthConfig(); + auth.enabled = false; + auth.hmac_keys = std::nullopt; + auth.anonymous_claim = std::optional>{ + std::vector{makeAnonymousScope({"frobnicate"})} + }; + cfg.services.value().emplace("svc", makeAuthService(std::move(auth))); + + auto result = resolveConfig(cfg); + ASSERT_TRUE(result.hasError()); + EXPECT_THAT(result.error(), HasSubstr("is not a recognized CAT4MOQ action")); +} + +TEST(ResolveConfig, AuthAnonymousClaimRejectsClientSetupAndServerSetup) { + auto cfg = makeMinimalInsecureConfig(); + cfg.services.value().clear(); + + auto auth = makeAuthConfig(); + auth.anonymous_claim = std::optional>{std::vector{ + makeAnonymousScope({"client_setup"}), + makeAnonymousScope({"server_setup"}), + makeAnonymousScope({"setup"}), // alias for client_setup + }}; + cfg.services.value().emplace("svc", makeAuthService(std::move(auth))); + + auto result = resolveConfig(cfg); + ASSERT_TRUE(result.hasError()); + EXPECT_THAT(result.error(), HasSubstr("the anonymous claim never authorizes session setup")); +} + +TEST(ResolveConfig, AuthAnonymousClaimRejectsEmptyActionsList) { + auto cfg = makeMinimalInsecureConfig(); + cfg.services.value().clear(); + + auto auth = makeAuthConfig(); + auth.anonymous_claim = + std::optional>{std::vector{makeAnonymousScope({})} + }; + cfg.services.value().emplace("svc", makeAuthService(std::move(auth))); + + auto result = resolveConfig(cfg); + ASSERT_TRUE(result.hasError()); + EXPECT_THAT(result.error(), HasSubstr("auth.anonymous_claim[0].actions must be non-empty")); +} + // --- Resolution tests --- TEST(ResolveConfig, MinimalInsecure) { diff --git a/test/config/ConfigSerializerTest.cpp b/test/config/ConfigSerializerTest.cpp index 3c14a2444..3cd6eb61a 100644 --- a/test/config/ConfigSerializerTest.cpp +++ b/test/config/ConfigSerializerTest.cpp @@ -73,13 +73,17 @@ static_assert( "CacheConfig changed — update serializeCache()" ); static_assert( - rfl::internal::num_fields == 6, + rfl::internal::num_fields == 8, "AuthConfig changed — update serializeAuth()" ); static_assert( rfl::internal::num_fields == 2, "HmacKey changed — update serializeAuth()" ); +static_assert( + rfl::internal::num_fields == 5, + "AnonymousScope changed — update serializeAuth()" +); static_assert( rfl::internal::num_fields == 4, "UpstreamConfig changed — update serializeUpstream()" @@ -226,6 +230,32 @@ TEST(ConfigSerializerTest, VisitsAllSections) { EXPECT_EQ(sink.scalars["logging.qlog.dir"], "/var/log/qlog"); } +// Regression: a scope without namespace_match (matches ANY namespace) must +// serialize distinguishably from one with an explicit empty segment list +// (matches only the zero-segment namespace) -- collapsing both to [] would +// make a wide-open anonymous claim unauditable over the /config endpoint. +TEST(ConfigSerializerTest, AnonymousClaimAbsentNamespaceMatchSerializesAsNull) { + Config cfg = makeFullConfig(); + auto& scope = cfg.services.at("default").auth.anonymousClaim.emplace_back(); + scope.actions.push_back(openmoq::moqx::auth::Action::Subscribe); + + RecordingSink sink; + serializeConfig(cfg, sink); + EXPECT_EQ(sink.scalars["services.default.auth.anonymous_claim.*.namespace_segments"], "null"); +} + +TEST(ConfigSerializerTest, AnonymousClaimEmptyNamespaceMatchSerializesAsEmptyArray) { + Config cfg = makeFullConfig(); + auto& scope = cfg.services.at("default").auth.anonymousClaim.emplace_back(); + scope.actions.push_back(openmoq::moqx::auth::Action::Subscribe); + scope.namespaceSegments = std::vector{}; + + RecordingSink sink; + serializeConfig(cfg, sink); + // An empty array emits no scalar leaf; the null sentinel must be absent. + EXPECT_EQ(sink.scalars.count("services.default.auth.anonymous_claim.*.namespace_segments"), 0u); +} + TEST(ConfigSerializerTest, RedactsHmacSecret) { Config cfg = makeFullConfig(); RecordingSink sink; diff --git a/test/relay/AuthFiltersTest.cpp b/test/relay/AuthFiltersTest.cpp index 47c5da339..95a014009 100644 --- a/test/relay/AuthFiltersTest.cpp +++ b/test/relay/AuthFiltersTest.cpp @@ -28,6 +28,7 @@ config::AuthConfig makeVerifierConfig() { .hmacKeys = {config::AuthConfig::HmacKey{.id = "k1", .secret = "secret"}}, .requireSetupToken = true, .allowRequestTokenOverride = true, + .maxTokensPerMessage = 4, }; } @@ -193,3 +194,89 @@ TEST_F(AuthFiltersTest, PublishSucceedsWithRequestTokenWhenSessionGrantsDoNotCov auto result = filter->publish(std::move(pub), nullptr); EXPECT_TRUE(result.hasValue()); } + +// --- Anonymous claim: end-to-end through the auth filters --- + +// A service-wide floor lets anyone subscribe/fetch without a token, while +// publish still requires one -- even with sessionGrants empty (as if +// require_setup_token: false and no setup token was ever presented). +TEST( + AuthFiltersAnonymousClaimTest, + SubscribeAndFetchAllowedAnonymouslyWhilePublishStillNeedsToken +) { + auto config = makeVerifierConfig(); + config.anonymousClaim = { + config::AuthConfig::AnonymousScope{.actions = {Action::Subscribe, Action::Fetch}}, + }; + auto verifier = std::make_shared(config); + auto publisherInner = std::make_shared>(); + auto subscriberInner = std::make_shared>(); + auto emptySessionGrants = std::make_shared>(); + + AuthPublisherFilter + pubFilter(publisherInner, verifier, emptySessionGrants, /*peeringEnabled=*/false); + AuthSubscriberFilter subFilter(subscriberInner, verifier, emptySessionGrants); + + // Subscribe: anonymous claim covers it. + SubscribeOk subOk; + subOk.requestID = RequestID(1); + auto subHandle = std::make_shared>(subOk); + EXPECT_CALL(*publisherInner, subscribe(_, _)) + .WillOnce( + [subHandle](SubscribeRequest, std::shared_ptr) + -> folly::coro::Task { + co_return folly::makeExpected( + std::shared_ptr(subHandle) + ); + } + ); + SubscribeRequest sub; + sub.requestID = RequestID(1); + sub.fullTrackName = FullTrackName{TrackNamespace{{"live"}}, "video"}; + EXPECT_TRUE(folly::coro::blockingWait(pubFilter.subscribe(std::move(sub), nullptr)).hasValue()); + + // Fetch: anonymous claim covers it. + EXPECT_CALL(*publisherInner, fetch(_, _)) + .WillOnce( + [](Fetch f, std::shared_ptr) -> folly::coro::Task { + auto handle = + std::make_shared>(FetchOk{.requestID = f.requestID}); + co_return folly::makeExpected(std::shared_ptr(handle + )); + } + ); + Fetch fetch( + RequestID(2), + FullTrackName{TrackNamespace{{"live"}}, "video"}, + AbsoluteLocation{0, 0}, + AbsoluteLocation{1, 0} + ); + EXPECT_TRUE(folly::coro::blockingWait(pubFilter.fetch(std::move(fetch), nullptr)).hasValue()); + + // Publish: the anonymous claim doesn't cover it -- rejected without a token. + PublishRequest pubNoToken; + pubNoToken.requestID = RequestID(3); + pubNoToken.fullTrackName = FullTrackName{TrackNamespace{{"live"}}, "video"}; + auto noTokenResult = subFilter.publish(std::move(pubNoToken), nullptr); + ASSERT_FALSE(noTokenResult.hasValue()); + EXPECT_EQ(noTokenResult.error().errorCode, PublishErrorCode::UNAUTHORIZED); + + // Publish: a per-request token additively unlocks it. + EXPECT_CALL(*subscriberInner, publish(_, _)) + .WillOnce( + [](PublishRequest p, std::shared_ptr) -> Subscriber::PublishResult { + PublishOk ok; + ok.requestID = p.requestID; + return Subscriber::PublishConsumerAndReplyTask{ + .consumer = nullptr, + .reply = + folly::coro::makeTask>(std::move(ok)), + }; + } + ); + PublishRequest pubWithToken; + pubWithToken.requestID = RequestID(4); + pubWithToken.fullTrackName = FullTrackName{TrackNamespace{{"live"}}, "video"}; + pubWithToken.params = withAuthToken(FrameType::PUBLISH, makeSignedToken({Action::Publish})); + EXPECT_TRUE(subFilter.publish(std::move(pubWithToken), nullptr).hasValue()); +} From dc49f4c0f3c25867471805da6b3d1ed8073ef4e9 Mon Sep 17 00:00:00 2001 From: Michal Hosna Date: Tue, 11 Aug 2026 14:59:26 +0000 Subject: [PATCH 2/4] auth: add ENFORCE_EXHAUSTIVE_SWITCH helper macros Hides the compiler-specific pragmas behind one header and covers MSVC; a switch over an enum with a missed enumerator stays a compile error on GCC/clang/MSVC. Also enables -Wswitch-enum, which plain -Wswitch left out. --- src/auth/Action.h | 7 ++++--- src/auth/Auth.cpp | 8 ++++---- src/auth/ExhaustiveSwitch.h | 22 ++++++++++++++++++++++ 3 files changed, 30 insertions(+), 7 deletions(-) create mode 100644 src/auth/ExhaustiveSwitch.h diff --git a/src/auth/Action.h b/src/auth/Action.h index f73cec410..125efba4a 100644 --- a/src/auth/Action.h +++ b/src/auth/Action.h @@ -6,6 +6,8 @@ #pragma once +#include "auth/ExhaustiveSwitch.h" + #include #include @@ -33,8 +35,7 @@ enum class Action : uint64_t { enum class MatchRuleType : uint64_t { Exact = 0, Prefix = 1, Suffix = 2, Contains = 3 }; -#pragma GCC diagnostic push -#pragma GCC diagnostic error "-Wswitch" +ENFORCE_EXHAUSTIVE_SWITCH_BEGIN inline std::string_view actionName(Action action) { switch (action) { case Action::ClientSetup: @@ -58,7 +59,7 @@ inline std::string_view actionName(Action action) { } return "unknown"; } -#pragma GCC diagnostic pop +ENFORCE_EXHAUSTIVE_SWITCH_END // Canonicalizes an action name (aliases, numeric IDs, case/dash-insensitive) // to its Action, or nullopt if unrecognized. diff --git a/src/auth/Auth.cpp b/src/auth/Auth.cpp index d16868857..d77d87936 100644 --- a/src/auth/Auth.cpp +++ b/src/auth/Auth.cpp @@ -6,6 +6,7 @@ #include "auth/Auth.h" #include "auth/CborReader.h" +#include "auth/ExhaustiveSwitch.h" #include "auth/HmacKey.h" #include @@ -43,9 +44,8 @@ std::string toString(const std::vector& bytes) { // catapult's CBOR decoder (parse_bin_match, cwt.cpp) falls back to EXACT for // an unrecognized BinaryMatchType, so the XLOG below is a tripwire against -// that decoder changing; -Wswitch (a hard error here) covers a missed case. -#pragma GCC diagnostic push -#pragma GCC diagnostic error "-Wswitch" +// that decoder changing; the exhaustive-switch check covers a missed case. +ENFORCE_EXHAUSTIVE_SWITCH_BEGIN MatchRule::Type fromCatapultMatchType(catapult::BinaryMatchType type) { switch (type) { case catapult::BinaryMatchType::EXACT: @@ -61,7 +61,7 @@ MatchRule::Type fromCatapultMatchType(catapult::BinaryMatchType type) { << " unrecognized; falling back to EXACT"; return MatchRule::Type::Exact; } -#pragma GCC diagnostic pop +ENFORCE_EXHAUSTIVE_SWITCH_END std::vector fromCatapultMatch(const catapult::MoqtCompoundMatch& match) { if (match.is_empty()) { diff --git a/src/auth/ExhaustiveSwitch.h b/src/auth/ExhaustiveSwitch.h new file mode 100644 index 000000000..71c5c9a92 --- /dev/null +++ b/src/auth/ExhaustiveSwitch.h @@ -0,0 +1,22 @@ +/* + * Copyright (c) OpenMOQ contributors. + * This source code is licensed under the Apache 2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +#pragma once + +// Wrap a switch over an enum to make a missing enumerator a compile error (promote from warning), +// Use only around switches with no default +#if defined(_MSC_VER) +#define ENFORCE_EXHAUSTIVE_SWITCH_BEGIN __pragma(warning(push)) __pragma(warning(error : 4061 4062)) +#define ENFORCE_EXHAUSTIVE_SWITCH_END __pragma(warning(pop)) +#elif defined(__GNUC__) || defined(__clang__) +#define ENFORCE_EXHAUSTIVE_SWITCH_BEGIN \ + _Pragma("GCC diagnostic push") _Pragma("GCC diagnostic error \"-Wswitch\"") \ + _Pragma("GCC diagnostic error \"-Wswitch-enum\"") +#define ENFORCE_EXHAUSTIVE_SWITCH_END _Pragma("GCC diagnostic pop") +#else +#define ENFORCE_EXHAUSTIVE_SWITCH_BEGIN +#define ENFORCE_EXHAUSTIVE_SWITCH_END +#endif From 467f6907e5cd6aa6195ecf8f7f711bc2989e9494 Mon Sep 17 00:00:00 2001 From: Michal Hosna Date: Tue, 11 Aug 2026 14:59:33 +0000 Subject: [PATCH 3/4] auth: use folly::Endian for canonicalNamespace length prefix Replaces the hand-rolled byte shifts. Wire bytes are unchanged (big-endian length prefix). --- src/auth/Auth.h | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/auth/Auth.h b/src/auth/Auth.h index 3152c70f6..71fcc3872 100644 --- a/src/auth/Auth.h +++ b/src/auth/Auth.h @@ -11,6 +11,7 @@ #include #include +#include #include #include @@ -57,10 +58,8 @@ struct Grants { inline std::string canonicalNamespace(const moxygen::TrackNamespace& ns) { std::string out; for (const auto& field : ns.trackNamespace) { - out.push_back(static_cast((field.size() >> 24) & 0xff)); - out.push_back(static_cast((field.size() >> 16) & 0xff)); - out.push_back(static_cast((field.size() >> 8) & 0xff)); - out.push_back(static_cast(field.size() & 0xff)); + const uint32_t lenBigEndian = folly::Endian::big(static_cast(field.size())); + out.append(reinterpret_cast(&lenBigEndian), sizeof(lenBigEndian)); out.append(field); } return out; From 4b5c60f29f5285232a3eb448ec7c714e6992755b Mon Sep 17 00:00:00 2001 From: Michal Hosna Date: Tue, 11 Aug 2026 18:09:48 +0000 Subject: [PATCH 4/4] auth: warn when anonymous_claim is set but auth is disabled - The claim is inert with enabled: false (nothing is enforced), so a config carrying one likely expects enforcement that isn't happening. --strict_config promotes the warning to an error. - docs/config.md now states enabled: false disables all enforcement. --- docs/config.md | 2 +- src/config/ConfigResolver.cpp | 22 ++++++++++++++++------ test/config/ConfigResolverTest.cpp | 21 +++++++++++++++++++++ 3 files changed, 38 insertions(+), 7 deletions(-) diff --git a/docs/config.md b/docs/config.md index 61b0d265f..22dd6c16a 100644 --- a/docs/config.md +++ b/docs/config.md @@ -174,7 +174,7 @@ services: | Field | Default | Notes | |---|---|---| -| `enabled` | `false` | Enables CAT-style authorization for this service. | +| `enabled` | `false` | Enables CAT-style authorization for this service. When `false`, nothing is enforced and the other `auth` fields have no effect. | | `token_type` | `0` | MOQT `AUTHORIZATION_TOKEN` type to accept. Use `16` with CAT4MOQ tokens produced for moqxr's CAT wrapper. Type `0` is valid for private or out-of-band deployments. The value must fit in a QUIC variable integer. | | `hmac_keys` | empty | Required when `enabled: true`. Each key needs a non-empty `id` and `secret`; duplicate key IDs are rejected. The token issuer must use the same key ID and secret. | | `require_setup_token` | `true` | Requires a valid setup token authorizing `client_setup` during session setup. If `false`, clients can connect without setup grants; per-request actions still need an authorized token or a matching `anonymous_claim` entry. | diff --git a/src/config/ConfigResolver.cpp b/src/config/ConfigResolver.cpp index 94d66d8bb..a89725aaf 100644 --- a/src/config/ConfigResolver.cpp +++ b/src/config/ConfigResolver.cpp @@ -534,7 +534,8 @@ constexpr uint64_t kQuicVarintExclusiveUpperBound = uint64_t{1} << 62; void validateAuth( const std::string& serviceName, const ParsedAuthConfig& auth, - std::vector& errors + std::vector& errors, + std::vector& warnings ) { if (auth.enabled.value()) { const auto& keys = auth.hmac_keys.value(); @@ -583,6 +584,13 @@ void validateAuth( if (!anonymousClaim.has_value()) { return; } + if (!auth.enabled.value() && !anonymousClaim->empty()) { + warnings.push_back( + "Service '" + serviceName + + "': auth.anonymous_claim has no effect while auth.enabled is false -- everything is " + "allowed" + ); + } for (size_t i = 0; i < anonymousClaim->size(); ++i) { const auto& scope = (*anonymousClaim)[i]; const auto prefix = @@ -693,7 +701,8 @@ void validateServiceAuth( const ParsedServiceConfig& svc, const ParsedConfig& config, std::unordered_map& mergedAuths, - std::vector& errors + std::vector& errors, + std::vector& warnings ) { if (!svc.auth.value().has_value()) { return; @@ -705,7 +714,7 @@ void validateServiceAuth( mergedAuth.max_tokens_per_message = config.service_defaults.value()->auth.value()->max_tokens_per_message.value(); } - validateAuth(name, mergedAuth, errors); + validateAuth(name, mergedAuth, errors, warnings); mergedAuths.emplace(name, std::move(mergedAuth)); } @@ -718,7 +727,8 @@ void validateService( std::unordered_set& compositeKeys, std::unordered_map& mergedCaches, std::unordered_map& mergedAuths, - std::vector& errors + std::vector& errors, + std::vector& warnings ) { // Validate each match entry @@ -790,7 +800,7 @@ void validateService( if (svc.upstream.value().has_value()) { validateUpstream(*svc.upstream.value(), errors); } - validateServiceAuth(name, svc, config, mergedAuths, errors); + validateServiceAuth(name, svc, config, mergedAuths, errors, warnings); } std::string generateRelayID() { @@ -1256,7 +1266,7 @@ folly::Expected resolveConfig(const ParsedConfig& c std::unordered_map mergedAuths; for (const auto& [name, svc] : services) { - validateService(name, svc, config, compositeKeys, mergedCaches, mergedAuths, errors); + validateService(name, svc, config, compositeKeys, mergedCaches, mergedAuths, errors, warnings); } // === Validate threads === diff --git a/test/config/ConfigResolverTest.cpp b/test/config/ConfigResolverTest.cpp index 63e4d25b8..fc987bacc 100644 --- a/test/config/ConfigResolverTest.cpp +++ b/test/config/ConfigResolverTest.cpp @@ -953,6 +953,27 @@ TEST(ResolveConfig, AuthAnonymousClaimValidatedEvenWhenAuthDisabled) { EXPECT_THAT(result.error(), HasSubstr("is not a recognized CAT4MOQ action")); } +TEST(ResolveConfig, AuthAnonymousClaimWarnsWhenAuthDisabled) { + auto cfg = makeMinimalInsecureConfig(); + cfg.services.value().clear(); + + auto auth = makeAuthConfig(); + auth.enabled = false; + auth.hmac_keys = std::nullopt; + auth.anonymous_claim = std::optional>{ + std::vector{makeAnonymousScope({"subscribe"})} + }; + cfg.services.value().emplace("svc", makeAuthService(std::move(auth))); + + auto result = resolveConfig(cfg); + ASSERT_TRUE(result.hasValue()); + ASSERT_FALSE(result.value().warnings.empty()); + EXPECT_THAT( + result.value().warnings[0], + HasSubstr("auth.anonymous_claim has no effect while auth.enabled is false") + ); +} + TEST(ResolveConfig, AuthAnonymousClaimRejectsClientSetupAndServerSetup) { auto cfg = makeMinimalInsecureConfig(); cfg.services.value().clear();