From 00acedf78f07f0528d8877f87faa2665d2561156 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 2 Sep 2026 13:19:37 -0700 Subject: [PATCH] chore(quest): plan the mTLS auth path behind the proxy mode Co-Authored-By: Claude Fable 5.1 --- ...rs-bypass-auth-api-mode-so-proxy-grants.md | 59 ++++++++------- quest/m0/README.md | 2 +- quest/m2/README.md | 2 +- quest/m2/auth-verdict.md | 73 ++++++++----------- 4 files changed, 66 insertions(+), 70 deletions(-) diff --git a/quest/m0/3087-relay-mtls-peers-bypass-auth-api-mode-so-proxy-grants.md b/quest/m0/3087-relay-mtls-peers-bypass-auth-api-mode-so-proxy-grants.md index 2de0d0fa0f..796d7705b2 100644 --- a/quest/m0/3087-relay-mtls-peers-bypass-auth-api-mode-so-proxy-grants.md +++ b/quest/m0/3087-relay-mtls-peers-bypass-auth-api-mode-so-proxy-grants.md @@ -1,34 +1,43 @@ -# [M] relay: mTLS peers bypass --auth-api-mode, so proxy grants can't refuse or scope them +# [M] relay: mTLS peers bypass the auth API mode, so a proxy grant cannot refuse or scope them ## Goal -Implement and verify the behavior tracked in [#3087](https://github.com/moq-dev/moq/issues/3087) -within the issue's stated scope and boundaries. +An mTLS peer is authorized through the same auth API path as every other +connection. In proxy mode the endpoint's grant, or its absence, decides what +the peer may publish and subscribe; in token mode a certificate-authenticated +peer with no grant stays unrestricted, so a deployment answering +`{alias, tier}` today is unaffected. Fixes on dev, where the mode lands. ## Plan -Use the public issue's scope, implementation notes, and acceptance criteria -below as the starting plan. Reconcile paths and assumptions with the current -tree before implementation. - -### Issue context - -`--auth-api-mode proxy` (#3044) lets an auth endpoint decide every connection - except mTLS ones, which bypass the mode entirely. - -`Auth::verify_mtls` calls `resolve_mtls`, which builds its own request and reads only `alias` and `tier` off the reply. A `200 {}`, or a reply carrying a deliberately narrow `grant`, still becomes an unrestricted publish-and-subscribe token. So an operator delegating authorization to their endpoint cannot refuse or scope an mTLS client through the documented grant response. `resolve_mtls` also hardcodes `host: None`, so host-routed tenants dialing the same path produce indistinguishable lookups. - -#### Shape - -Stop special-casing mTLS in the authorization path. It should build its request through `api_request` (sending `mtls=true`, and `host` in proxy mode) and resolve through `authorize`, like any other connection: - -- **token mode**: `mtls=true` satisfies "has a credential" without a JWT or `key` - the cert *is* the token. Absent a grant the peer stays unrestricted, exactly as today, so existing deployments returning `{alias, tier}` are unaffected. -- **proxy mode**: the endpoint returns a `grant` like anyone else, and no grant is a refusal - consistent with the rest of the mode. - -#### Deliberately NOT in scope: revalidation - -mTLS peers keep `revalidate: None`. Not because mTLS is precious, but because a deployed endpoint sending a blanket `Cache-Control: max-age` on every reply would silently arm revalidation on a production relay mesh the moment the relay ships - gating fleet interconnect on that endpoint staying reachable, with no one having chosen it. - -If mesh revalidation is wanted later it should be its own change, with the endpoint opting in deliberately for `mtls=true`, and a **relay-side floor** on the staleness window for those requests so an operator who forgets the header doesn't get a fleet that partitions on the first auth blip. Note `stale-if-error` alone is not sufficient protection: it only applies when the endpoint *errors*, so an endpoint that successfully answers "no" still partitions the mesh instantly. +`Auth::verify_mtls` in `rs/moq-relay/src/auth.rs` calls `resolve_mtls`, which +builds its own `AuthApiRequest`, reads only `alias` and `tier` off the reply, +and mints `AuthToken::unrestricted`. Nothing the endpoint returns can narrow +that, and the request carries no `host`, so host-routed tenants dialing the +same path are indistinguishable at the endpoint. + +- Delete `resolve_mtls`. Build the request through `api_request` with + `mtls: true`, plus `host` in proxy mode as the mode already sends it, and + resolve through `authorize`, so a grant is read one way for every credential. +- Token mode: `mtls: true` satisfies "has a credential" without a JWT or a + `key`; the certificate is the token. No grant means unrestricted, as today. +- Proxy mode: the endpoint returns a grant like anyone else, and no grant is a + refusal, consistent with the rest of the mode. +- `revalidate` stays `None` for mTLS peers, which the "mTLS peers must never + revalidate" test already pins. A deployed endpoint sending a blanket + `Cache-Control: max-age` would otherwise arm revalidation across a production + relay mesh the moment this ships, gating fleet interconnect on that endpoint + staying reachable. Mesh revalidation is its own change: an explicit opt-in + for `mtls=true` and a relay-side floor on the staleness window. Note + `stale-if-error` alone is not enough, since an endpoint that successfully + answers "no" still partitions the mesh. +- Tests: a proxy-mode mTLS peer refused by an empty reply, scoped by a narrow + grant, and admitted unrestricted in token mode; `host` present on the proxy + request. Update the mTLS section of `doc/bin/relay/auth.md`. + +## Required + +- [Auth verdict](/quest/m2/auth-verdict.md) - lands the proxy mode, the grant response, and the `host` field this builds on ## Closes diff --git a/quest/m0/README.md b/quest/m0/README.md index 984213b1b5..511524dc0f 100644 --- a/quest/m0/README.md +++ b/quest/m0/README.md @@ -15,7 +15,7 @@ regression test per Root Cause First. ## Quests -- [#3087](/quest/m0/3087-relay-mtls-peers-bypass-auth-api-mode-so-proxy-grants.md) - relay: mTLS peers bypass --auth-api-mode, so proxy grants can't refuse or scope them +- [#3087](/quest/m0/3087-relay-mtls-peers-bypass-auth-api-mode-so-proxy-grants.md) - relay: mTLS peers bypass the auth API mode, so a proxy grant cannot refuse or scope them - [#2405](/quest/m0/2405-js-net-connect-logs-on-every-connection-at-the-wrong.md) - js/net: connect() logs on every connection at the wrong level and prints the JWT in the URL - [Dart leaks](/quest/m0/dart-leak.md) - the generated Dart bindings leak native memory on every call - [#3207](/quest/m0/3207-send-valid-publish-done-statuses-for-every-supported-ietf.md) - Send valid PUBLISH_DONE statuses for every supported IETF draft diff --git a/quest/m2/README.md b/quest/m2/README.md index de707ed576..3aab380ba5 100644 --- a/quest/m2/README.md +++ b/quest/m2/README.md @@ -59,7 +59,7 @@ dashboards, fleet rollout) stay downstream. - [#709](/quest/m2/709-automatic-letsencrypt-support.md) - Automatic LetsEncrypt support - [Room SDK](/quest/m2/room-sdk.md) - a headless room package: a room is a path prefix, no service, no storage - [LiveKit shim](/quest/m2/livekit-shim.md) - a drop-in livekit-client-compatible package running rooms over MoQ -- [Auth verdict](/quest/m2/auth-verdict.md) - the relay hands an opaque credential to its auth API and is told the grant +- [Auth verdict](/quest/m2/auth-verdict.md) - the relay hands an opaque credential to its auth API and is told the grant; lands as the proxy mode in #3044 - [#1310](/quest/m2/1310-why-use-the-worklet-plugin.md) - why use the worklet plugin? - [Ship capture and playback](/quest/m2/cli-packaging.md) - a released moq binary can capture and play, which no distribution currently enables - [Windows capture parity](/quest/m2/capture-windows.md) - window, app, system-audio and cursor capture on Windows diff --git a/quest/m2/auth-verdict.md b/quest/m2/auth-verdict.md index 148b174c80..ddbce498c5 100644 --- a/quest/m2/auth-verdict.md +++ b/quest/m2/auth-verdict.md @@ -3,49 +3,36 @@ ## Goal moq-relay can hand an opaque credential to its auth API and be told the grant, -instead of resolving a `kid` and verifying a JWT locally. +instead of resolving a `kid` and verifying a JWT locally. Ships as +`--auth-api-mode proxy`. ## Plan -- Today the relay's auth-API request carries the connection path, the `kid`, an - mTLS flag, and the transport, and never the credential itself. Nothing - downstream of the relay can decide anything a local signature check could - not, so the protocol change is the whole unit of work. It is what blocks an - operator's auth endpoint from forwarding authorization to its own customers - (moq.pro's downstream bring-your-own-auth Worker builds on this, and stays - there). -- Carry the credential as `Authorization: Bearer ` on the existing - GET, and have the endpoint answer with `Vary: Authorization`. That keeps the - response cacheable per credential while keeping a bearer secret out of URLs and - access logs. Fall back to a request body only if the relay's cache middleware - mishandles `Vary`; either way, record which and why in the PR. -- Send the credential for a VERDICT lookup only, and never on key resolution. - Varying a key response on the credential would split one `kid` into a cache - variant per JWT, so a shared-key audience that costs one request per relay per - cadence today would cost one per viewer - collapsing the property that makes the - JWT path the scale path, and for nothing, since the key a `kid` resolves to - does not depend on which token presented it. Key responses stay keyed on - `kid`, path, and transport. -- The response returns the grant directly (publish and subscribe scopes plus an - expiry) beside the alias and tier it already returns, instead of a key. Keep - ONE endpoint and ONE response type: verdict mode is a response shape, not a - second flag, so the same endpoint can answer with a key for one connection and - a grant for another and an operator migrates per connection rather than per - deployment. -- Reconcile with revalidation in the same change. The re-check decides a grant is - gone by the absence of a key, and a verdict grant has no key, so verdict-mode - sessions would close on their first re-check. "Still vouched for" has to become - "the response still carries a grant or a key" before either feature is correct - with the other enabled. -- There is no JWT to read an `exp` from, so the response's expiry becomes the - outer bound, and its absence leaves the revalidation cadence as the only bound. - Say so where the endpoint contract is documented: an endpoint that returns - neither is asking for a session that ends only when the API says so. -- The caching trade-off belongs in `doc/bin/relay/auth.md` next to the contract, - not in a design note: a credential shared across an audience caches like a - `kid` and costs one request per relay per cadence, while a per-viewer credential - costs a request per viewer. That sentence lets an operator choose deliberately - instead of discovering the bill. -- Test coverage mirrors the existing auth-API suite: a granted verdict, a - refused one, a malformed body, the cache behavior under `Vary`, and a - verdict-mode session surviving a re-check. +[#3044](https://github.com/moq-dev/moq/pull/3044) is the implementation and +targets dev. Landing it completes this quest; delete the quest in that PR. It +settled the design points this quest used to hold open, and they are recorded +here so review does not relitigate them: + +- A mode, not a response shape. Letting one endpoint answer with a `key` for + one connection and a `grant` for another put both paths inside a single + request: which cache key applies, whether the credential may be sent, what + "still vouched for" means. Choosing once per relay deletes all of it. The + mode lives on `AuthApi`, so it rides on a session's grant and a proxy-admitted + session re-checked by a token-mode instance keeps its grant. +- The credential travels as `Authorization: Bearer` on the existing GET. The + relay caches on a SHA-256 of it and declares the cache private, so an endpoint + that forgets `Vary: Authorization` cannot cross-serve grants and the secret + stays out of logs and metrics. +- Refusal is `404`, an empty grant, or in proxy mode a `401`/`403`. Token mode + and anonymous proxy connections carry no credential, so there those statuses + stay an outage rather than disconnecting an audience over a gateway blip. +- Each re-check's `exp` replaces the last. In token mode the JWT's own `exp` is + a ceiling a reply may lower but never raise. +- `--auth-api-mode proxy` excludes `--auth-domain`, and a mode without + `--auth-api` is a startup error. +- `doc/bin/relay/auth.md` carries the mode, refusal statuses, expiry, cache + semantics, and the cost trade: a credential shared across an audience caches + like a `kid`, a per-viewer credential costs a request per viewer. + +What the PR leaves out on purpose is the mTLS path, which still bypasses the +mode; that is [#3087](/quest/m0/3087-relay-mtls-peers-bypass-auth-api-mode-so-proxy-grants.md).