Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion doc/bin/relay/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ never filled from a `Host` header or a WebTransport authority. The client still
the relay serves, so a hostname-scoped server binds the tenant to the
credential, not to `server_name` alone.

`host` is the host the client dialed, without its port: the `Host` header or
URL authority on HTTP, WebSocket, and WebTransport, and the SNI on raw QUIC and
`tls://`. Unlike `server_name` it is present over plaintext, so a server that
scopes tenants by hostname can read `host`; it is just as client-chosen.

**Connect.** A 2xx with a grant admits. A 403 can carry JSON such as
`{"reason":"expired","root":"rooms/123","tier":"edge"}`. The reason is
`refused`, `forbidden`, `expired`, or `malformed` (the token does not parse);
Expand Down Expand Up @@ -497,7 +502,7 @@ patterns: `--auth-public 'PREFIX/**'`.
| `--auth-mtls-tier LABEL` | `--tier LABEL` (one tier per server) |
| `listen.tls.root` alone admitting a peer unscoped | `--mtls-publish '**' --mtls-subscribe '**' --mtls-link peer` |
| `--auth-api` (token or proxy mode), `Cache-Control` | `--auth-url` pointed at any server answering the contract; `revalidate` and `expires` in the grant |
| `--auth-domain` | your server reads `server_name` and decides |
| `--auth-domain` | your server reads `host` (present over plaintext too) and decides |

Both on one host:

Expand Down
4 changes: 4 additions & 0 deletions js/auth/api/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -681,6 +681,7 @@ export const RequestSchema: ZodMiniDiscriminatedUnion<[ZodMiniObject<{
alpn: ZodMiniOptional<ZodMiniString<string>>;
auth: ZodMiniOptional<ZodMiniString<string>>;
event: ZodMiniLiteral<"connect">;
host: ZodMiniOptional<ZodMiniString<string>>;
id: ZodMiniString<string>;
implementation: ZodMiniOptional<ZodMiniString<string>>;
local: ZodMiniOptional<ZodMiniString<string>>;
Expand Down Expand Up @@ -720,6 +721,7 @@ export const RequestSchema: ZodMiniDiscriminatedUnion<[ZodMiniObject<{
alpn: ZodMiniOptional<ZodMiniString<string>>;
auth: ZodMiniString<string>;
event: ZodMiniLiteral<"attach">;
host: ZodMiniOptional<ZodMiniString<string>>;
id: ZodMiniString<string>;
implementation: ZodMiniOptional<ZodMiniString<string>>;
local: ZodMiniOptional<ZodMiniString<string>>;
Expand Down Expand Up @@ -759,6 +761,7 @@ export const RequestSchema: ZodMiniDiscriminatedUnion<[ZodMiniObject<{
alpn: ZodMiniOptional<ZodMiniString<string>>;
auth: ZodMiniOptional<ZodMiniString<string>>;
event: ZodMiniLiteral<"revalidate">;
host: ZodMiniOptional<ZodMiniString<string>>;
id: ZodMiniString<string>;
implementation: ZodMiniOptional<ZodMiniString<string>>;
local: ZodMiniOptional<ZodMiniString<string>>;
Expand Down Expand Up @@ -803,6 +806,7 @@ export const RequestSchema: ZodMiniDiscriminatedUnion<[ZodMiniObject<{
}, $strip>;
duration: ZodMiniNumber<number>;
event: ZodMiniLiteral<"end">;
host: ZodMiniOptional<ZodMiniString<string>>;
id: ZodMiniString<string>;
implementation: ZodMiniOptional<ZodMiniString<string>>;
local: ZodMiniOptional<ZodMiniString<string>>;
Expand Down
1 change: 1 addition & 0 deletions js/auth/src/contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ test("a connect request parses with every field", () => {
remote: "203.0.113.9:4433",
local: "[::1]:443",
server_name: "relay.example",
host: "relay.example",
alpn: "moq-lite-05",
path: "/demo/room",
query: "jwt=abc",
Expand Down
6 changes: 6 additions & 0 deletions js/auth/src/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,12 @@ const BaseRequestSchema = z.object({
* server binds the tenant to the credential, not to this alone.
*/
server_name: z.optional(z.string()),
/**
* The host the client dialed, without its port: the `Host` header or URL authority on
* HTTP, WebSocket, and WebTransport, the SNI on raw QUIC and `tls://`. Client-chosen
* even over plaintext, so bind the tenant to the credential, not to this alone.
*/
host: z.optional(z.string()),
/** The negotiated application protocol, including the moq version. */
alpn: z.optional(z.string()),
/** The path exactly as dialed. */
Expand Down
1 change: 1 addition & 0 deletions rs/moq-auth/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Auth requests carry `host`, the host the client dialed: the `Host` header on HTTP and WebSocket, where `server_name` stays the TLS SNI only. A server that scopes tenants by hostname can now scope plaintext sessions.
- Token claims and grants carry optional bitrate caps again, as additive `publish_rate` and `subscribe_rate` (`Rate`, bits per second) beside the patterns. A capped role is written as `{"path": [...], "rate": bps}`, which released verifiers refuse rather than drop the cap; key scopes still refuse it.

### Changed
Expand Down
8 changes: 8 additions & 0 deletions rs/moq-auth/src/request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ pub struct Request {
/// server binds the tenant to the credential, not to this alone.
pub server_name: Option<String>,

/// The host the client dialed, without its port: the `Host` header or URL authority on
/// HTTP, WebSocket, and WebTransport, the SNI on raw QUIC and `tls://`. Client-chosen
/// even over plaintext, so bind the tenant to the credential, not to this alone.
pub host: Option<String>,

/// The negotiated application protocol, including the moq version.
pub alpn: Option<String>,

Expand Down Expand Up @@ -79,6 +84,7 @@ impl std::fmt::Debug for Request {
.field("remote", &self.remote)
.field("local", &self.local)
.field("server_name", &self.server_name)
.field("host", &self.host)
.field("alpn", &self.alpn)
.field("path", &self.path)
// The contract permits credentials under any query key.
Expand Down Expand Up @@ -109,6 +115,7 @@ impl Request {
remote: None,
local: None,
server_name: None,
host: None,
alpn: None,
path: path.into(),
query: None,
Expand Down Expand Up @@ -278,6 +285,7 @@ mod tests {
request.remote = Some("203.0.113.9:4433".parse().unwrap());
request.local = Some("[::1]:443".parse().unwrap());
request.server_name = Some("relay.example".into());
request.host = Some("relay.example".into());
request.alpn = Some("moq-lite-05".into());
request.query = Some("jwt=abc".into());
request.role = Some(Role::Publisher);
Expand Down
1 change: 1 addition & 0 deletions rs/moq-relay/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1254,6 +1254,7 @@ pub fn request_for(auth: &Auth, request: &moq_tokio::server::Request) -> Request
// Only the SNI: a WebTransport CONNECT authority or WebSocket `Host` is client-chosen
// without having selected a certificate, so a session without SNI reports none.
out.server_name = request.server_name().map(str::to_owned);
out.host = request.authority().map(str::to_owned);
out.alpn = request.alpn().map(str::to_owned);
out.role = request.role().and_then(role);
out.implementation = request.peer_implementation().map(str::to_owned);
Expand Down
6 changes: 6 additions & 0 deletions rs/moq-relay/src/uring.rs
Original file line number Diff line number Diff line change
Expand Up @@ -815,6 +815,12 @@ impl crate::incoming::Sealed for Request {
request.local = Some(self.local);
// Only the SNI, like the tokio listener: the CONNECT host selected no certificate.
request.server_name = self.sni.clone();
request.host = self
.url
.as_ref()
.and_then(|url| url.host_str())
.map(str::to_owned)
.or_else(|| self.sni.clone());
request.alpn = self.alpn.clone();
request.tls = self.identity.as_ref().and_then(auth::peer);
request
Expand Down
73 changes: 67 additions & 6 deletions rs/moq-relay/src/web.rs
Original file line number Diff line number Diff line change
Expand Up @@ -914,15 +914,20 @@ impl<'de> serde::Deserialize<'de> for FetchGroup {
}

/// Admit a one-shot HTTP read: the auth server sees the path, the raw query, the
/// SNI on HTTPS, the peer, and any certificate. Only a grant that may
/// SNI on HTTPS, the dialed host, the peer, and any certificate. Only a grant that may
/// subscribe counts as a request; the returned origin meters reads under its token.
// The `Err` is axum's own `ErrorResponse`, so there is nothing here to box.
#[expect(clippy::result_large_err, reason = "the error type is axum's, not ours")]
#[expect(
clippy::too_many_arguments,
reason = "one fact per axum extractor the handlers pass through"
)]
async fn admit_http(
state: &WebState,
path: String,
query: AuthQuery,
uri: &http::Uri,
headers: &http::HeaderMap,
server_name: Option<Extension<ServerName>>,
remote: net::SocketAddr,
mtls: Option<Extension<MtlsPeer>>,
Expand All @@ -937,6 +942,7 @@ async fn admit_http(
.map(str::to_owned)
.or_else(|| query.jwt.map(|jwt| format!("jwt={jwt}")));
request.server_name = server_name.map(|Extension(ServerName(name))| name);
request.host = dialed_host(uri, headers);
request.remote = Some(remote);
request.tls = mtls.and_then(|Extension(MtlsPeer(identity))| auth::peer(&identity));
// The listener's timeout bounds admission as it bounds the request headers. An
Expand All @@ -959,24 +965,44 @@ async fn admit_http(
Ok((lease.with_stats(stats), origin))
}

/// The host an HTTP request dialed, without its port: the HTTP/2 `:authority`, else
/// the `Host` header. Lowercased to match the URL-derived transports, whose
/// `Url::host_str` lowercases a domain.
fn dialed_host(uri: &http::Uri, headers: &http::HeaderMap) -> Option<String> {
let header = || {
headers
.get(http::header::HOST)
.and_then(|host| host.to_str().ok())
.and_then(|host| host.parse::<http::uri::Authority>().ok())
};
let authority = uri.authority().cloned().or_else(header)?;
let host = authority.host();
(!host.is_empty()).then(|| host.to_ascii_lowercase())
}

/// Serve the announced broadcasts for a given prefix.
// The `Err` is axum's own `ErrorResponse`, so there is nothing here to box.
#[expect(clippy::result_large_err, reason = "the error type is axum's, not ours")]
#[expect(
clippy::too_many_arguments,
reason = "one axum extractor per fact the admission needs"
)]
async fn serve_announced(
path: Option<Path<String>>,
Query(query): Query<AuthQuery>,
mtls: Option<Extension<MtlsPeer>>,
server_name: Option<Extension<ServerName>>,
ConnectInfo(remote): ConnectInfo<net::SocketAddr>,
uri: http::Uri,
headers: http::HeaderMap,
State(state): State<Arc<WebState>>,
) -> axum::response::Result<String> {
let prefix = match path {
Some(Path(prefix)) => prefix,
None => String::new(),
};

let (lease, origin) = admit_http(&state, prefix, query, &uri, server_name, remote, mtls).await?;
let (lease, origin) = admit_http(&state, prefix, query, &uri, &headers, server_name, remote, mtls).await?;
let mut announced = origin.routes();
let mut broadcasts = Vec::new();

Expand All @@ -1000,13 +1026,18 @@ async fn serve_announced(
/// Serve the given group for a given track
// The `Err` is axum's own `ErrorResponse`, so there is nothing here to box.
#[expect(clippy::result_large_err, reason = "the error type is axum's, not ours")]
#[expect(
clippy::too_many_arguments,
reason = "one axum extractor per fact the admission needs"
)]
async fn serve_fetch(
Path(path): Path<String>,
Query(params): Query<FetchParams>,
mtls: Option<Extension<MtlsPeer>>,
server_name: Option<Extension<ServerName>>,
ConnectInfo(remote): ConnectInfo<net::SocketAddr>,
uri: http::Uri,
headers: http::HeaderMap,
State(state): State<Arc<WebState>>,
) -> axum::response::Result<ServeGroup> {
// The path containts a broadcast/track
Expand All @@ -1018,7 +1049,17 @@ async fn serve_fetch(
return Err(StatusCode::BAD_REQUEST.into());
}

let (lease, origin) = admit_http(&state, path.join("/"), params.auth, &uri, server_name, remote, mtls).await?;
let (lease, origin) = admit_http(
&state,
path.join("/"),
params.auth,
&uri,
&headers,
server_name,
remote,
mtls,
)
.await?;
// The token's root is the canonical (alias-resolved) broadcast path.
let broadcast = lease.token().root.to_string();

Expand Down Expand Up @@ -1301,6 +1342,7 @@ mod tests {
None,
remote,
"/fetch/room/video".parse().unwrap(),
http::HeaderMap::new(),
State(state.clone()),
)
.await
Expand All @@ -1315,6 +1357,7 @@ mod tests {
None,
remote,
"/announced/room".parse().unwrap(),
http::HeaderMap::new(),
State(state.clone()),
)
.await
Expand Down Expand Up @@ -1367,6 +1410,7 @@ mod tests {
None,
ConnectInfo("127.0.0.1:1234".parse().unwrap()),
"/fetch/room/video".parse().unwrap(),
http::HeaderMap::new(),
State(state.clone()),
)
.await;
Expand All @@ -1378,6 +1422,7 @@ mod tests {
None,
ConnectInfo("127.0.0.1:1234".parse().unwrap()),
"/announced/room".parse().unwrap(),
http::HeaderMap::new(),
State(state.clone()),
)
.await;
Expand Down Expand Up @@ -1452,6 +1497,7 @@ mod tests {
None,
ConnectInfo("127.0.0.1:1234".parse().unwrap()),
format!("/fetch/room/video?tier={tier}").parse().unwrap(),
http::HeaderMap::new(),
State(state.clone()),
)
.await
Expand All @@ -1468,6 +1514,7 @@ mod tests {
None,
ConnectInfo("127.0.0.1:1234".parse().unwrap()),
format!("/announced/room?tier={tier}").parse().unwrap(),
http::HeaderMap::new(),
State(state.clone()),
)
.await
Expand Down Expand Up @@ -1563,6 +1610,7 @@ mod tests {
None,
ConnectInfo("127.0.0.1:1234".parse().unwrap()),
"/fetch/room/video?group=0".parse().unwrap(),
http::HeaderMap::new(),
State(state),
)
.await
Expand Down Expand Up @@ -1604,6 +1652,7 @@ mod tests {
None,
peer(),
"/fetch/room/video".parse().unwrap(),
http::HeaderMap::new(),
State(state.clone()),
)
.await;
Expand All @@ -1615,6 +1664,7 @@ mod tests {
None,
peer(),
"/announced/room".parse().unwrap(),
http::HeaderMap::new(),
State(state.clone()),
)
.await;
Expand Down Expand Up @@ -1968,7 +2018,8 @@ mod tests {

/// `server_name` is the SNI that selected the certificate, on an HTTP read and a
/// WebSocket alike, never the `Host` the client wrote; plain HTTP has no SNI, so it
/// reports none rather than the header. Real sockets, so this runs on the wall clock.
/// reports none rather than the header. `host` carries the header on both, lowercased and
/// without its port. Real sockets, so this runs on the wall clock.
#[cfg(all(unix, feature = "websocket"))]
#[tokio::test]
async fn server_name_is_the_sni_never_the_host() {
Expand Down Expand Up @@ -2015,19 +2066,24 @@ mod tests {

let mut stream = connect(tls).await;
stream
.write_all(b"GET /announced HTTP/1.1\r\nHost: tenant.example\r\nConnection: close\r\n\r\n")
.write_all(b"GET /announced HTTP/1.1\r\nHost: Tenant.Example:8443\r\nConnection: close\r\n\r\n")
.await
.unwrap();
let admission = admissions.next().await.expect("an HTTP admission");
assert_eq!(admission.request.transport, moq_auth::Transport::Http);
assert_eq!(admission.request.server_name.as_deref(), expected, "HTTP, tls={tls}");
assert_eq!(
admission.request.host.as_deref(),
Some("tenant.example"),
"HTTP, tls={tls}"
);
// Unanswered, the read fails as unavailable; only the request mattered.
drop(admission);
let _ = stream.read_to_end(&mut Vec::new()).await;

let mut stream = connect(tls).await;
stream
.write_all(b"GET /room HTTP/1.1\r\nHost: tenant.example\r\n")
.write_all(b"GET /room HTTP/1.1\r\nHost: Tenant.Example:8443\r\n")
.await
.unwrap();
let client = tokio::spawn(drain::upgrade_while_draining(stream, true));
Expand All @@ -2038,6 +2094,11 @@ mod tests {
expected,
"WebSocket, tls={tls}"
);
assert_eq!(
admission.request.host.as_deref(),
Some("tenant.example"),
"WebSocket, tls={tls}"
);
admission.grant(moq_auth::lease::Consumer::fixed(moq_auth::Grant::new(
moq_auth::Pattern::all(),
moq_auth::Pattern::all(),
Expand Down
4 changes: 2 additions & 2 deletions test/interop/interop.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2546,8 +2546,8 @@ run_catalog_fanout() {
done
for reader in "${readers[@]}"; do harness_wait "$reader" || true; done
for version in "${CATALOG_VERSIONS[@]}"; do
want="catalog compress=false video=hd,sd audio=en,fr"
[[ "$version" != moq-lite-07-wip ]] || want="catalog compress=true video=hd,sd audio=en,fr"
want="catalog compression=none video=hd,sd audio=en,fr"
[[ "$version" != moq-lite-07-wip ]] || want="catalog compression=deflate video=hd,sd audio=en,fr"
# Like run_native, a verified result survives the NAPI addon's exit crash.
grep -qx "$want" "$LOGS/$name-$version.log" || why="${why:-$version reader did not report: $want}"
done
Expand Down
Loading