Skip to content

feat(auth): CAT bearer-token authorization (default-off auth-cat feature) - #235

Merged
englishm merged 26 commits into
cloudflare:mainfrom
englishm-cloudflare:me/cat-auth-hook
Oct 6, 2026
Merged

englishm merged 26 commits into
cloudflare:mainfrom
englishm-cloudflare:me/cat-auth-hook

Conversation

@englishm-cloudflare

@englishm-cloudflare englishm-cloudflare commented Sep 9, 2026 •

Copy link
Copy Markdown
Contributor

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-cat crate 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 without auth-cat" rather than silently admitting sessions. Embedders that want token enforcement enable the feature and supply ScopeAuthConfig through Coordinator::get_scope_config.

Model

  1. resolve_scope() identifies which scope a connection belongs to (unchanged).
  2. get_scope_config() supplies that scope's ScopeAuthConfig with the public keys, issuers, audiences, and clock-skew tolerance.
  3. AuthHook::on_setup runs once per session, before either session half is constructed.
  4. AuthHook::on_request runs 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 Option check 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 (=) prevents cargo update from pulling in an unreviewed later alpha. ring is a transitive dependency of cat-token; a boring/native-tls backend is tracked separately.

API changes (breaking — moq-relay-ietf 0.8.0)

  • Producer::new (pub) removed. It could only produce an unauthenticated producer. Use new_with_upstream_namespaces with an explicit auth: Option<SessionAuth>.
  • Consumer::new narrowed to pub(crate). The relay constructs it internally with the session's resolved auth state.
  • New pub types: AuthHook, ScopeAuthConfig, AuthPublicKey, AuthToken, Principal, AllowAllAuthHook, and related error/decision types.

Known limitation — CAT claim shape

CAT-4-MOQT PR #48 changes the moqt claim encoding from a flat array to a map under key 0. cat-token 0.3.0-alpha.2 implements 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 of cat-token will be needed; the wire format is not yet finalized.

Testing

250+ unit tests in moq-relay-ietf with --features auth-cat, 196 without. Coverage includes:

  • All alias code points and malformed-framing cases
  • Verification against each of up to five keys; kid selection and ordering
  • Expiry mid-session and the skew boundary
  • Scope-config caching, TTL expiry, rotation, and the broken-config path
  • SUBSCRIBE_NAMESPACE fan-out authorization (per-track gate, not just prefix)
  • Standalone and joining FETCH authorization via may_fetch_track
  • FetchWriter::reject_with after prepare_response (regression test)
  • DPoP-bound token rejection; cnf/catdpop claim rejection
  • Duplicate and unknown claim keys; float nbf enforcement

Relates to

Builds on @suhasHere PoC; redesigned for production with two corrected wire-format bugs and hardened claim handling.

englishm and others added 23 commits October 6, 2026 18:07
…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 englishm-cloudflare changed the title feat(auth): add scope-configured CAT bearer authorization feat(auth): CAT bearer-token authorization (default-off auth-cat feature) Oct 6, 2026
@englishm-cloudflare
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.
@englishm
englishm merged commit 3a96f6c into cloudflare:main Oct 6, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants