diff --git a/config.example.yaml b/config.example.yaml index 897e5068..13e9cd42 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 4fe4ba05..22dd6c16 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,20 +166,71 @@ 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 | |---|---|---| -| `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 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 02e0704f..63d59495 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 00000000..125efba4 --- /dev/null +++ b/src/auth/Action.h @@ -0,0 +1,104 @@ +/* + * 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 "auth/ExhaustiveSwitch.h" + +#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 }; + +ENFORCE_EXHAUSTIVE_SWITCH_BEGIN +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"; +} +ENFORCE_EXHAUSTIVE_SWITCH_END + +// 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 6db4a3ab..d77d8793 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 @@ -30,18 +31,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 +42,10 @@ 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; the exhaustive-switch check covers a missed case. +ENFORCE_EXHAUSTIVE_SWITCH_BEGIN 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; } +ENFORCE_EXHAUSTIVE_SWITCH_END 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 f6296db0..71fcc387 100644 --- a/src/auth/Auth.h +++ b/src/auth/Auth.h @@ -6,10 +6,12 @@ #pragma once +#include "auth/Action.h" #include "config/Config.h" #include #include +#include #include #include @@ -23,18 +25,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 +32,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 +52,19 @@ 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) { + const uint32_t lenBigEndian = folly::Endian::big(static_cast(field.size())); + out.append(reinterpret_cast(&lenBigEndian), sizeof(lenBigEndian)); + out.append(field); + } + return out; +} + class AuthTokenVerifier { public: explicit AuthTokenVerifier(config::AuthConfig config); @@ -70,6 +74,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 +101,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 +144,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 27d0ff92..a010429f 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/auth/ExhaustiveSwitch.h b/src/auth/ExhaustiveSwitch.h new file mode 100644 index 00000000..71c5c9a9 --- /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 diff --git a/src/config/Config.h b/src/config/Config.h index 4ddb41c3..f5d639ec 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 ee274465..a89725aa 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 { @@ -533,38 +534,137 @@ 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()) { + 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" + 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" ); - } 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 +677,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 +686,38 @@ 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, + std::vector& warnings +) { + 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, warnings); + mergedAuths.emplace(name, std::move(mergedAuth)); +} + // --- Service validation --- void validateService( @@ -596,7 +726,9 @@ void validateService( const ParsedConfig& config, std::unordered_set& compositeKeys, std::unordered_map& mergedCaches, - std::vector& errors + std::unordered_map& mergedAuths, + std::vector& errors, + std::vector& warnings ) { // Validate each match entry @@ -668,9 +800,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, warnings); } std::string generateRelayID() { @@ -1002,7 +1132,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 +1149,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 +1263,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, warnings); } // === Validate threads === @@ -1176,7 +1311,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 fea889b3..45f97826 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 e66efcae..d9fa243b 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 b693d6d9..06526474 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 330f1172..e5f129ae 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 2b722a94..fc987bac 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,208 @@ 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, 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(); + + 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 3c14a244..3cd6eb61 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 47c5da33..95a01400 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()); +}