Repository navigation
feat(auth): CAT bearer-token authorization (default-off auth-cat feature) - #235
Merged
Merged
Conversation
…t PUBLISH_NAMESPACE rejection Two additions the relay needs to authorize sessions and requests, both useful independently of any particular authorization scheme. Session::setup_params() exposes the CLIENT_SETUP parameters the peer sent. The transport already decodes them but interprets only PATH and MAX_REQUEST_ID, discarding the rest -- including AUTHORIZATION TOKEN (draft-16 s9.3.1.5), which is by design an application-layer concern. The whole parameter list is exposed rather than that one parameter so the transport stays agnostic about which parameters the application cares about, and because AUTHORIZATION TOKEN may be repeated (s9.2.2.1), which a single extracted value could not represent. PublishedNamespace::reject() sends a REQUEST_ERROR with a caller-chosen code, mirroring SubscribedNamespace::reject. Previously the only rejection path was close(), whose Drop impl hardcodes UNINTERESTED, so a subscriber could not distinguish "not interested" from "not permitted". close() keeps its existing behaviour.
Add a generic AuthHook boundary to moq-relay-ietf and an optional Common Access Token implementation. Authorization policy comes from the coordinator per scope, allowing independent issuers, audiences, ES256 public-key sets, clock-skew tolerances, and cache lifetimes. Decode draft-16 AUTHORIZATION TOKEN parameters at session setup and validate CAT credentials through their COSE/CWT representation. Verify signatures only at setup; retain the established principal for request-time claim evaluation while continuing to enforce expiry on new requests. Authorize PUBLISH_NAMESPACE, PUBLISH, SUBSCRIBE, SUBSCRIBE_NAMESPACE, and TRACK_STATUS before any lookup, registration, or metadata disclosure. Apply the same checks to namespace discovery and per-track SUBSCRIBE_NAMESPACE fan-out so a prefix grant cannot expose or deliver tracks outside its scope. Fail closed on unavailable or invalid scope configuration, malformed setup parameters, unsupported or undecoded restriction claims, DPoP-bound bearer tokens, duplicate claims, implausible lifetimes, and builds without CAT support. Bound setup work across presented tokens and configured keys. Keep token bytes redacted in logs and mlog output, zeroize the relay-owned credential copy, preserve explicit rejection codes and reasons on the wire, and expose low-cardinality authorization metrics for denials and hook faults. The CAT dependency remains off by default and pinned to the upstream COSE API until that API is published.
Add a token-type-agnostic AuthorizationToken value and outbound Session, Publisher, and Subscriber APIs that emit repeated AUTHORIZATION TOKEN parameters in CLIENT_SETUP. Encode USE_VALUE followed by the QUIC-varint token type and opaque token bytes, without an inner value length. Allow moq-clock-ietf to present repeated TYPE:BASE64URL credentials for end-to-end testing. Accept decimal and hexadecimal token types, padded and unpadded base64url, and reject values outside the QUIC varint range. Redact credentials from AuthorizationToken, CLIENT_SETUP, and mlog debug rendering. Document that the testing CLI exposes credentials in process arguments. Finish integration hardening by authorizing peer-forwarded namespace advertisements before they mutate discovery state, removing a dependency left unused by the COSE migration, and aligning metric and API documentation with the emitted behavior.
Collapse base().with_replay_protection(...) onto one line (cat.rs:2019). The two-line form was introduced by the cat-token 0.3.0-alpha.2 migration; cargo fmt flags it against the workspace rustfmt settings.
A caret requirement on a pre-release ("0.3.0-alpha.2") permits any
0.3.0-alpha.N or 0.3.0 release, whose API is still changing. The exact
pin prevents a future cargo update from pulling in an unreviewed version.
moq-relay-ietf is a published crate; semver-incompatible alpha churn
could silently break downstream users and our own builds after a lockfile
update.
A scope of ["sports"] (no nil terminator) is a prefix grant and must authorise subscribing to namespace ["sports", "football"]. The reverse — a nil-terminated scope must NOT match deeper namespaces — is already covered by a_nil_terminated_scope_hides_deeper_namespaces. The prefix direction (scope-element matches any namespace whose first field equals the scope element, including deeper namespaces) is verified by this test. If this test fails the prefix-direction logic is inverted and must be fixed before merging.
cat.rs: explain that the manual nbf range check is a sanity guard only; the authoritative not-before enforcement is in verified.validate() via token_validator. Documents why the two-pass approach is correct and intentional. producer.rs: document why may_announce_namespace uses SubscribeNamespace rather than Subscribe (discovery-level disclosure, not track-level grant), and why converting TrackNamespace to TrackNamespacePrefix is the correct way to invoke the prefix-matching logic for announcement authorization.
The CAT auth rebase removed Producer::new (which silently granted all requests) in favour of new_with_upstream_namespaces, and added auth as a required parameter to Consumer::new. The standalone-fetch tests from public-main still used the old signatures. Update all three Producer::new call sites to create UpstreamNamespaces explicitly and pass None for auth (no token enforcement in FETCH tests), and add the missing None auth argument to the two Consumer::new call sites. Behaviour is identical; the change is mechanical API alignment.
…t catv gap cat.rs: The test a_load_bearing_claim_in_an_unsupported_encoding_is_refused had an inaccurate docstring claiming that float nbf would be 'dropped by the decoder' — actually the library accepts and enforces float nbf via safe_float_to_i64. The five test cases exercise distinct mechanisms: library parse-fail (text/bool encodings), library enforce (float nbf via validate()), and validate_moqt_claims (integer moqt-reval). The comment now describes each case's actual protection layer. cat.rs: document the catv version-check gap — the relay calls validate_moqt_claims, which does not enforce catv <= 1. The intentional trade-off (unenforceable_claim is the primary gate) is recorded at the call site so future maintainers do not silently widen the catv allowlist. producer.rs: cargo fmt correction in UpstreamNamespaces::new call.
serve_fetch now calls may_fetch_track() before any local or remote lookup. A standalone FETCH retrieves track content — the same content a SUBSCRIBE delivers — so it requires the same Subscribe grant. Joining FETCH reaches the application layer via fetch.resolve(), which resolves it to a standalone range; may_fetch_track then authorises the resolved track under the Subscribe grant. Extract may_fetch_track as a free function (same pattern as may_serve_track_in_fanout) to make the decision unit-testable without a full transport session. Add three tests: producer::tests::fetch_is_authorized_as_subscribe producer::tests::fetch_is_withheld_for_unauthorized_track producer::tests::fetch_permits_everything_when_no_policy_applies Update AuthzOperation doc comment to reflect the current design: FETCH is not a separate variant because serve_fetch maps it to Subscribe. Also correct inline comment in a_load_bearing_claim_in_an_unsupported_ encoding_is_refused: the 'dropped by the decoder' label was inaccurate for Float(future) and Integer(300) encodings; updated to neutral phrasing.
AGENTS.md + auth/mod.rs: add standalone FETCH to the enforcement-point list. serve_fetch checks may_fetch_track (Subscribe grant) before any lookup; the list previously omitted this. auth/cat.rs module rustdoc: 'Any other claim is refused' was inaccurate — PERMITTED_CLAIM_KEYS allows sub, iat and cti as identity/informational claims. Rewording: 'claims the relay can neither enforce nor safely ignore are refused'. auth/cat.rs:191: add safety rationale for .dangerously_allow_unencrypted_privacy_claims(). It is safe because the raw-claim allowlist (unenforceable_claim) rejects privacy claim keys before validate() runs. auth/types.rs: make AuthToken.value private. expose_value() already exists as the explicit-exposure accessor. A pub field puts an unreexported type in the public API. Tests in the same module still compile (Rust module-privacy rules). producer.rs may_announce_namespace: clarify that the nil terminator decides whether a prefix scope allows deeper namespaces.
The previous rewording listed only sub/iat/cti as the permitted non-enforced claims, creating a false impression. PERMITTED_CLAIM_KEYS also allows catv/catifdata/catr/moqt-reval for distinct CAT-specific reasons. Updated the prose to say the full permitted set is documented in the unenforceable_claim table, pointing readers there rather than attempting an exhaustive inline summary.
… marker note cat.rs: remove date-and-name attribution from two-pass nbf comment and namespace-scope test docstring. Attribution belongs in git history, not in source code. metrics.rs: add moq_relay_fetch_errors_total to the metrics table and describe_metrics(). Add 'unauthorized' to the subscribe-latency HELP text (it was in the module doc but missing from the Prometheus HELP description string). producer.rs: add a doc note to new_with_upstream_namespaces explaining the breaking change (Producer::new removed). This makes the breaking change visible in file diffs in addition to the conventional-commit marker commit. README.md: add Authorization section pointing to ScopeConfig.auth, the auth-cat feature, and auth/mod.rs for the full model.
Add a note explaining the TYPE:BASE64URL format, the use case (testing against CAT-enforced scopes), and the warning that token values are visible in process arguments. The flag was added by the moq-clock-ietf changes in this feature branch and was missing from the dev guide.
… caveat The --announce relay-to-relay connection is relay-initiated and does not present a CAT token; it logs a warning when entering an auth-enabled scope. Narrowing 'every session' to 'every accepted client session' and adding the known-gap caveat prevents the README from overstating the enforcement coverage.
This commit carries the breaking-change marker for API removals that were introduced in the CAT bearer-token implementation (commit c538a36) and are intentional. No code is added or changed here. Producer::new (pub) is removed. It could only construct an unauthenticated producer, which would silently bypass any CAT policy the scope has configured. Forcing callers through new_with_upstream_namespaces with an explicit auth parameter prevents accidental policy bypass. Consumer::new is narrowed to pub(crate) for the same reason. The relay constructs it internally with the session's resolved auth state. Neither constructor had in-tree callers outside the relay's own session setup code (relay.rs). This is an intentional breaking change. BREAKING CHANGE: Producer::new (pub) is removed; use new_with_upstream_namespaces with an explicit auth: Option<SessionAuth>. Consumer::new is now pub(crate) and no longer reachable from outside the crate.
…dflare#247 rebase Regenerated after rebasing onto the FETCH-complete public-main (PRs cloudflare#237, cloudflare#246, cloudflare#247, plus cloudflare#248-cloudflare#250 fixes). Pinned proc-macro2 to 1.0.101 to satisfy all dependency constraints while keeping versions compatible with the workspace.
…onse; declare tokio rt
fetch_requested.rs: FetchWriteCommand::Reject checked 'responded ||
pending_response.is_some()' and returned Duplicate even when FETCH_OK
had not been committed to the wire. pending_response holds a staged but
uncommitted response — it must not block rejection. Fix: check only
'responded' (which tracks wire state) and discard pending_response when
rejecting, since FETCH_OK was never sent.
Cargo.toml: declare 'rt' in moq-transport's tokio features. tokio::spawn
is called unconditionally in start_writer (fetch_requested.rs:338) —
the first non-test spawn in this crate. The build currently compiles
only because workspace members enable 'rt' via tokio = { features =
["full"] }; making the dependency explicit prevents latent breakage
for standalone consumers.
…th regression tests auth/types.rs: the note 'joining FETCH is still refused by the transport with NOT_SUPPORTED' was stale after the FETCH PRs. Joining FETCH now reaches the application layer via fetch.resolve() which resolves it to a standalone range; may_fetch_track then authorises it under the Subscribe grant. Updated wording covers both fetch types. fetch_requested.rs: add regression tests for the FetchWriter::reject_with fix. 'reject_after_respond_sends_request_error_not_duplicate' verifies that calling reject_with after respond() (with FETCH_OK staged but not committed) sends REQUEST_ERROR with the caller's code and reason, not the generic InternalError the Drop impl would have sent. Also adds a guard test via FetchRequested::reject. Both tests pin the invariant that reject is gated on 'responded' (wire state), not on 'pending_response.is_some()' (staging state).
Rename 'reject_after_committed_fetch_ok_returns_duplicate' to 'reject_fresh_request_sends_request_error' to match what the test actually exercises: a direct FetchRequested::reject on a request with no staged response. Add a comment explaining that the committed-FETCH_OK Duplicate path requires a live QUIC stream and is an integration-test concern.
auth/mod.rs used a rustdoc link [`may_fetch_track`] to a private free function in producer.rs. Private items are not linkable from a module- level doc, so rustdoc warns. Use plain backticks instead.
The original wording 'runs exactly as it did before this module existed' was inaccurate: authorize_session still validates AUTHORIZATION TOKEN wire format (draft-16 §9.2.2.1) even for no-policy scopes. Only token enforcement (admission gating) is absent. Reworded to say 'no token enforcement; format validation still applies'.
englishm-cloudflare
force-pushed
the
me/cat-auth-hook
branch
from
October 6, 2026 18:24
95ac454 to
9851f33
Compare
englishm-cloudflare
marked this pull request as ready for review
October 6, 2026 18:26
error.reason is ReasonPhrase(pub String) not &str. The assertion assert_eq!(error.reason, "not found") failed to compile with E0308 mismatched types on the public CI runner. Fix: assert_eq!(error.reason.0, "not found") accesses the inner String, which PartialEq<&str> covers.
…nstructors The joining-FETCH tests added by FETCH PRs cloudflare#237 and cloudflare#246 still called Producer::new(publisher, locals, remotes, coordinator, context), which was removed when auth was made an explicit parameter. Apply the same fix as the earlier passthrough-FETCH tests: create UpstreamNamespaces separately and pass None for auth (no token enforcement in these tests). Also add the missing None auth argument to one Consumer::new call in the two-relay joining-FETCH test.
async_trait injects #[must_use] on each generated method signature so callers must .await the returned future. std::future::Future is also #[must_use], so the generated Pin<Box<dyn Future<...>>> return type carries #[must_use] too. The combination triggers clippy::double_must_use, which became a hard error under Rust 1.99 stable with RUSTFLAGS=-D warnings. Same mechanism and fix as PR cloudflare#248 (coordinator.rs). Remove this allow when async-trait drops its #[must_use] injection or when rustc/clippy gains an exemption for proc-macro-generated annotations.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds CAT (Common Access Token, draft-ietf-moq-c4m) bearer-token authorization to
moq-relay-ietf. Authorization policy is per-scope and supplied by the coordinator rather than by CLI flags, so a multi-tenant relay can serve tenants with different issuers and rotate keys without a restart.Feature flag
The
auth-catcrate feature is off by default. Relays built without it still fail closed for any scope that requires tokens — the trait, wire decoding, and all enforcement points compile unconditionally, so a relay without the feature returns "relay built withoutauth-cat" rather than silently admitting sessions. Embedders that want token enforcement enable the feature and supplyScopeAuthConfigthroughCoordinator::get_scope_config.Model
resolve_scope()identifies which scope a connection belongs to (unchanged).get_scope_config()supplies that scope'sScopeAuthConfigwith the public keys, issuers, audiences, and clock-skew tolerance.AuthHook::on_setupruns once per session, before either session half is constructed.AuthHook::on_requestruns before each SUBSCRIBE, SUBSCRIBE_NAMESPACE, TRACK_STATUS, PUBLISH_NAMESPACE, PUBLISH, and standalone FETCH.A scope that returns no policy behaves exactly as before — no hook, no allocation, one
Optioncheck on the request path. Scopes that return a policy use the CAT hook to verify COSE_Sign1/ES256 tokens.Dependency
cat-token = { version = "=0.3.0-alpha.2", optional = true }— thanks to @suhasHere for the migration from the git-pinned revision to the published pre-release.The exact pin (
=) preventscargo updatefrom pulling in an unreviewed later alpha.ringis a transitive dependency ofcat-token; aboring/native-tlsbackend is tracked separately.API changes (breaking —
moq-relay-ietf 0.8.0)Producer::new(pub) removed. It could only produce an unauthenticated producer. Usenew_with_upstream_namespaceswith an explicitauth: Option<SessionAuth>.Consumer::newnarrowed topub(crate). The relay constructs it internally with the session's resolved auth state.AuthHook,ScopeAuthConfig,AuthPublicKey,AuthToken,Principal,AllowAllAuthHook, and related error/decision types.Known limitation — CAT claim shape
CAT-4-MOQT PR #48 changes the
moqtclaim encoding from a flat array to a map under key0.cat-token 0.3.0-alpha.2implements the pre-PR-#48 flat-array encoding. This PR targets that encoding. When PR #48 is accepted and a crate release includes it, a follow-up bump ofcat-tokenwill be needed; the wire format is not yet finalized.Testing
250+ unit tests in
moq-relay-ietfwith--features auth-cat, 196 without. Coverage includes:kidselection and orderingmay_fetch_trackFetchWriter::reject_withafterprepare_response(regression test)cnf/catdpopclaim rejectionnbfenforcementRelates to
Builds on @suhasHere PoC; redesigned for production with two corrected wire-format bugs and hardened claim handling.