diff --git a/doc/bin/relay/auth.md b/doc/bin/relay/auth.md index f9289e3f61..596f32e4a6 100644 --- a/doc/bin/relay/auth.md +++ b/doc/bin/relay/auth.md @@ -30,12 +30,14 @@ grant back. The schemas are `moq_auth::Request` and `moq_auth::Grant` `http` for a one-shot `/fetch` or `/announced` request), `remote` and `local` socket addresses, `server_name` (the SNI or the host the client addressed), `alpn` (the negotiated moq protocol), `path` exactly as dialed, `query` raw, -`role` the client declared at SETUP (`publisher`, `subscriber`, absent for -both), and `tls` with the verified client certificate when one was presented: +`token` with the credential a moq-transport client put in its SETUP's +`AUTHORIZATION TOKEN` option (`kind`, the draft's Token Type, and `value`, the +bytes as unpadded base64url), `role` the client declared at SETUP +(`publisher`, `subscriber`, absent for both), and `tls` with the verified client certificate when one was presented: `name` (first SAN DNS name, else CN, else the fingerprint; a server cannot tell which), `fingerprint` (SHA-256 of the leaf), `expires`, `issuer`. Nothing is parsed on the server's behalf: the `jwt` query parameter is a convention of `moq auth serve`, not of -the relay. An `end` adds `reason`, `duration` in seconds, and `bytes` sent and +the relay, and the relay forwards a SETUP token without reading it. An `end` adds `reason`, `duration` in seconds, and `bytes` sent and received. **Grant.** `publish` and `subscribe` as pattern unions (`foo/**` is a subtree, @@ -98,7 +100,7 @@ moq auth sessions --internal-url http://127.0.0.1:9101 --path 'demo/**' ``` `GET /sessions` is the dry run: the same matches, each request plus start -time, with `query` omitted so a jwt on the plane cannot be replayed. A +time, with `query` and `token` omitted so a credential on the plane cannot be replayed. A matching POST returns 202 and the ids; no match is 200 with an empty list. An unknown field, including `query`, is 400. One node, no cluster fan-out: the server already knows each session's `node` from `connect` and calls that @@ -142,7 +144,9 @@ moq auth verify --key public.jwk --in alice.jwt moq auth serve --key public.jwk # or --key-dir /etc/moq/keys/ for {kid}.jwk rotation ``` -The client dials `https://relay.example.com/rooms/123?jwt=`. HMAC +The client dials `https://relay.example.com/rooms/123?jwt=`. A +moq-transport client can instead put the JWT in its SETUP's `AUTHORIZATION +TOKEN` option with Token Type 0; `moq auth serve` verifies it the same way. HMAC (HS256/384/512), RSA (RS/PS), ECDSA (ES256/384), and EdDSA keys all work. A key can itself be **scoped** at generation (`--root`, `--publish`, `--subscribe`), after which it can never sign a broader token. @@ -237,14 +241,16 @@ moq auth serve --listen 127.0.0.1:4440 \ Policy runs in this order and stops at the first that applies: -1. A `jwt` in the query is verified against `--key FILE` or `--key-dir DIR` +1. A JWT, from the `jwt` query or a SETUP `token` of `kind` 0, is verified + against `--key FILE` or `--key-dir DIR` (by `kid`, read per request so rotation needs no restart). Its claims are authorized at the dialed path (`Claims::authorize`): the path may equal the root, extend it (which narrows the grant), or be a parent of it (the grant stays anchored at the root). Residuals become the grant. An unrelated path, or one the token grants nothing at, is refused. A malformed, expired, or unknown-key token is refused; it never falls - through to the anonymous rules. + through to the anonymous rules. So is a SETUP token of any other `kind`, + and a session presenting both a SETUP token and a `jwt` query. 2. A verified client certificate gets `--mtls-publish` and `--mtls-subscribe`, and nothing when they are empty. Cluster peers are admitted this way; a mesh needs `'**'` for both. diff --git a/doc/concept/standard.md b/doc/concept/standard.md index f2c7e9929e..282dcaf880 100644 --- a/doc/concept/standard.md +++ b/doc/concept/standard.md @@ -41,6 +41,15 @@ because they do not issue joining fetches. Other publishers may replay a cached backlog for that filter; selecting the next group instead would leave static tracks waiting for a group that never arrives. +A client may present one credential in its `SETUP` with the `AUTHORIZATION +TOKEN` option. The server reads a value (`USE_VALUE`, or `REGISTER`, which it +treats as a value since it advertises no token cache) and hands its Token Type +and bytes to the application unverified; a relay forwards them to its +[auth server](/bin/relay/auth#the-contract). An alias reference (`DELETE`, +`USE_ALIAS`) closes the session with `PROTOCOL_VIOLATION`, a structure that +does not decode with `KEY_VALUE_FORMATTING_ERROR`, and a second token is +refused. + Several project drafts extend the IETF wire without breaking it, since `SETUP` ignores unknown parameters: [cluster](/draft/moq-cluster) routing hop lists, [solicit](/draft/moq-solicit) to make announcements opt-in, diff --git a/doc/lib/rs/moq-auth.md b/doc/lib/rs/moq-auth.md index ddab0dc5a9..b406e0adf7 100644 --- a/doc/lib/rs/moq-auth.md +++ b/doc/lib/rs/moq-auth.md @@ -13,10 +13,10 @@ Everything a party needs to ask for or answer an authorization on answers the relay, in a service that mints tokens for clients, or in your own accept loop that decides in process. -- **Request and grant**: `Request` is the JSON a relay POSTs per session event (`connect`, `revalidate`, `end`) with everything it knows: id, node, transport, addresses, SNI and ALPN, the raw path and query, the declared role, and the verified certificate facts. `Grant` is the answer: `publish` and `subscribe` pattern unions, an optional `root` alias, `expires`, `revalidate`, `tier`, and `peer`, which marks the session as a cluster peer so the routes it announces report `Source::Peer`. `Grant::validate` refuses a grant that names nothing, asks to be revalidated without a bound or at no interval, or has already expired, with a few seconds of clock skew on `expires`. +- **Request and grant**: `Request` is the JSON a relay POSTs per session event (`connect`, `revalidate`, `end`) with everything it knows: id, node, transport, addresses, SNI and ALPN, the raw path and query, the moq-transport SETUP `token` (its Token Type and bytes, base64url on the wire), the declared role, and the verified certificate facts. `Grant` is the answer: `publish` and `subscribe` pattern unions, an optional `root` alias, `expires`, `revalidate`, `tier`, and `peer`, which marks the session as a cluster peer so the routes it announces report `Source::Peer`. `Grant::validate` refuses a grant that names nothing, asks to be revalidated without a bound or at no interval, or has already expired, with a few seconds of clock skew on `expires`. - **Lease**: `lease::Producer` and `lease::Consumer` are the handle a session holds for its grant. The consumer reads the current grant, waits for a change, and learns why the lease ended; the producer updates and revokes. Either side's terminal call returns the reason the lease actually ended with, so whichever got there first is what both report. `Consumer::fixed` is a grant nobody drives. `Consumer::revalidate` nudges a re-check now and the producer observes it via `poll_revalidate` or `revalidate_requested`, which is how the relay's session push lands. Whoever runs the accept loop builds the producer, so an embedder decides in process with no trait and no HTTP. Enforcing `expires` is the holder's job; the `Client` driver also revokes at expiry so its `end` event goes out. `Grant::deadline()` (feature `tokio`, also enabled by `client` and `serve`) snapshots expiry on Tokio's clock. Call it once per accepted grant and retain the deadline: future expiries are unchanged, and one already less than five seconds late gets the remainder of that skew window. - **Client**: `Client::new(url, tls)` and `Client::connect(request)` drive a lease against an auth server over `https://`, `unix://`, or loopback `http://`: revalidate on cadence with jittered backoff through an outage until `expires`, revoke on a 401/403 or an invalid grant, and POST `end` with the reason, duration, and byte totals the session reported through `lease::Consumer::close` when it ended. Dropping the consumer reports zero bytes. `end.reason` is `dropped`, `expired`, `refused`, `invalid`, or the session's own classification. -- **Server**: `serve::Policy` and `serve::Server` (feature `serve`) are the reference auth server behind `moq auth serve`: a `jwt` in the query verified against a key file or a `{kid}.jwk` directory, an explicit grant for verified certificates, the anonymous permissions, a tier, the revalidation cadence, a default `expires`, and live session caps per token and per remote address. A token is authorized at the dialed path with `Claims::authorize`; residuals become the grant. `Server::router` is an axum `POST /` you can mount in your own service. +- **Server**: `serve::Policy` and `serve::Server` (feature `serve`) are the reference auth server behind `moq auth serve`: a JWT from the `jwt` query or a type-0 SETUP token, verified against a key file or a `{kid}.jwk` directory (a token of another type, or both at once, is refused), an explicit grant for verified certificates, the anonymous permissions, a tier, the revalidation cadence, a default `expires`, and live session caps per token and per remote address. A token is authorized at the dialed path with `Claims::authorize`; residuals become the grant. `Server::router` is an axum `POST /` you can mount in your own service. - **Keys**: generate HS256/384/512, RS256/384/512, PS256/384/512, ES256/384, or EdDSA keys as JWKs, with a `kid` for rotation and an optional immutable scope that caps every token the key signs. - **Claims**: `root`, `publish`, `subscribe`, `exp`, `iat`. Grants are [`Pattern`](https://docs.rs/moq-pattern) unions: `foo` is one broadcast, `foo/**` is a subtree, `**` is everything. `Key::sign` and `Key::verify` handle the signature and expiry. Legacy `put`/`get` prefix claims and scopes read as subtrees (`p` is `p/**`), and grants that are all subtrees are written that way so older verifiers accept them. - **Authorization**: `Claims::authorize(path)` scopes verified claims to the path a client dialed and returns the publish and subscribe patterns relative to it, exactly as `moq auth serve` does. The relay forwards the raw path and enforces the grant it gets. diff --git a/js/auth/src/contract.test.ts b/js/auth/src/contract.test.ts index 3aed509742..aad28a2115 100644 --- a/js/auth/src/contract.test.ts +++ b/js/auth/src/contract.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { GrantSchema, RequestSchema } from "./contract.ts"; +import { GrantSchema, RequestSchema, TokenSchema } from "./contract.ts"; // The fixtures below are what the Rust `moq_auth::Request` and `Grant` serialize to, // so a server written against these schemas reads what a relay sends. @@ -52,6 +52,35 @@ test("an end request carries its facts beside the rest", () => { expect(invalid.reason).toBe("invalid"); }); +test("a SETUP token parses as the Rust vector", () => { + // What `moq_auth::Request` serializes a CAT of bytes 00 fb ff to. + const request = RequestSchema.parse( + JSON.parse( + '{"id":"00ff","event":"connect","node":"relay-1","transport":"quic","path":"/demo/room","token":{"kind":1,"value":"APv_"}}', + ), + ); + expect(request.token).toEqual({ kind: 1, value: "APv_" }); + + expect(() => + RequestSchema.parse({ + id: "1", + event: "connect", + node: "n", + transport: "quic", + path: "/", + token: { kind: 0, value: "AP+/" }, + }), + ).toThrow(); + + // Not a length or final character any byte string encodes to, which Rust refuses too. + for (const value of ["A", "AB", "APv_A"]) { + expect(() => TokenSchema.parse({ kind: 0, value })).toThrow(); + } + for (const value of ["", "AA", "AAA", "AAAA", "AQ", "AAE"]) { + expect(TokenSchema.parse({ kind: 0, value }).value).toBe(value); + } +}); + test("a connect must not carry end facts, and an unknown transport is refused", () => { expect(() => RequestSchema.parse({ id: "1", event: "end", node: "n", transport: "quic", path: "/" })).toThrow(); expect(() => diff --git a/js/auth/src/contract.ts b/js/auth/src/contract.ts index 3dfd527b73..c3371ba6e7 100644 --- a/js/auth/src/contract.ts +++ b/js/auth/src/contract.ts @@ -36,6 +36,17 @@ export const PeerSchema = z.object({ }); export type Peer = z.infer; +/** A credential from a moq-transport SETUP's `AUTHORIZATION TOKEN` option, unparsed. */ +export const TokenSchema = z.object({ + /** The moq-transport Token Type: 0 is negotiated out of band (a JWT to `moq auth serve`), 1 is a Common Access Token. */ + kind: z.int().check(z.nonnegative()), + /** The token bytes, base64url without padding. The final character must leave no stray bits, as Rust decodes it. */ + value: z + .string() + .check(z.regex(/^(?:[A-Za-z0-9_-]{4})*(?:[A-Za-z0-9_-][AQgw]|[A-Za-z0-9_-]{2}[AEIMQUYcgkosw048])?$/)), +}); +export type Token = z.infer; + /** Byte totals for a session, both directions from the relay's point of view. */ export const BytesSchema = z.object({ /** Bytes the relay sent to the peer. */ @@ -64,6 +75,8 @@ const BaseRequestSchema = z.object({ path: z.string(), /** The raw query string, without the leading `?`. */ query: z.optional(z.string()), + /** The credential a moq-transport client presented in its SETUP. */ + token: z.optional(TokenSchema), /** The direction the client declared at SETUP; absent means both. */ role: z.optional(RoleSchema), /** The verified client certificate, when one was presented. */ @@ -73,8 +86,8 @@ const BaseRequestSchema = z.object({ /** * Everything a relay knows about a session, sent to the auth server on every event. * - * Nothing is parsed on the relay's behalf: the server keys policy on the raw `path` - * and `query`, so no query parameter is special. The same shape carries every event; + * Nothing is parsed on the relay's behalf: the server keys policy on the raw `path`, + * `query`, and `token`, so no query parameter is special. The same shape carries every event; * an `end` adds what the session did. */ export const RequestSchema = z.discriminatedUnion("event", [ diff --git a/js/net/src/ietf/index.ts b/js/net/src/ietf/index.ts index e1ebb8199d..9c895e9e29 100644 --- a/js/net/src/ietf/index.ts +++ b/js/net/src/ietf/index.ts @@ -16,5 +16,6 @@ export * from "./solicit.ts"; export * from "./subscribe.ts"; export * from "./subscribe_namespace.ts"; export * from "./subscriber.ts"; +export * from "./token.ts"; export * from "./track.ts"; export * from "./version.ts"; diff --git a/js/net/src/ietf/token.test.ts b/js/net/src/ietf/token.test.ts new file mode 100644 index 0000000000..5d4b4137d9 --- /dev/null +++ b/js/net/src/ietf/token.test.ts @@ -0,0 +1,155 @@ +import { expect, test } from "bun:test"; +import { SessionCode, SessionError } from "../error.ts"; +import { Reader, Writer } from "../stream.ts"; +import * as Varint from "../varint.ts"; +import { SetupOption, SetupOptions } from "./parameters.ts"; +import { TOKEN_OUT_OF_BAND, type Token, tokenFromSetup, tokenIntoSetup } from "./token.ts"; +import { type IetfVersion, Version } from "./version.ts"; + +const VERSIONS: IetfVersion[] = [ + Version.DRAFT_14, + Version.DRAFT_15, + Version.DRAFT_16, + Version.DRAFT_17, + Version.DRAFT_18, + Version.DRAFT_19, + Version.DRAFT_20, + Version.DRAFT_21, + Version.DRAFT_22, +]; + +// A kind past one varint byte and a value that is not text, matching the Rust tests. +const TOKEN: Token = { kind: 300n, value: new Uint8Array([0x00, 0xff, 0x03, 0x80, 0x6a]) }; + +function leadingOnes(version: IetfVersion): boolean { + return version !== Version.DRAFT_14 && version !== Version.DRAFT_15 && version !== Version.DRAFT_16; +} + +function varint(v: bigint, version: IetfVersion): Uint8Array { + return leadingOnes(version) ? Varint.encodeLeadingOnes(v) : Varint.encode(v); +} + +/** The option as it arrives, after a trip through the SETUP parameter block. */ +async function received(params: SetupOptions, version: IetfVersion): Promise { + const chunks: Uint8Array[] = []; + const writer = new Writer( + new WritableStream({ + write(chunk) { + chunks.push(new Uint8Array(chunk)); + }, + }), + version, + ); + await params.encode(writer, version); + writer.close(); + await writer.closed; + + const bytes = new Uint8Array(chunks.reduce((total, chunk) => total + chunk.byteLength, 0)); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return SetupOptions.decode(new Reader(undefined, bytes, version), version); +} + +/** A raw Token structure: varint fields then a value. */ +function structure(version: IetfVersion, fields: bigint[], value: Uint8Array = new Uint8Array()): SetupOptions { + const parts = [...fields.map((field) => varint(field, version)), value]; + const raw = new Uint8Array(parts.reduce((total, part) => total + part.length, 0)); + let offset = 0; + for (const part of parts) { + raw.set(part, offset); + offset += part.length; + } + const params = new SetupOptions(); + params.setBytes(SetupOption.AuthorizationToken, raw); + return params; +} + +function sessionCode(fn: () => unknown): SessionCode | undefined { + try { + fn(); + } catch (err) { + if (err instanceof SessionError) return err.code; + throw err; + } + return undefined; +} + +test("USE_VALUE round trips on every draft", async () => { + for (const version of VERSIONS) { + const params = new SetupOptions(); + tokenIntoSetup(params, TOKEN, version); + expect(tokenFromSetup(await received(params, version), version)).toEqual(TOKEN); + } +}); + +/** The same bytes `rs/moq-net/src/ietf/token.rs` asserts, so the two agree on the wire. */ +test("the encoding matches the cross-language vector", () => { + const token: Token = { kind: 300n, value: new Uint8Array([0x00, 0xff]) }; + for (const [version, expected] of [ + [Version.DRAFT_14, [0x03, 0x41, 0x2c, 0x00, 0xff]], + [Version.DRAFT_17, [0x03, 0x81, 0x2c, 0x00, 0xff]], + ] as const) { + const params = new SetupOptions(); + tokenIntoSetup(params, token, version); + expect(params.getBytes(SetupOption.AuthorizationToken)).toEqual(new Uint8Array(expected)); + } +}); + +test("an absent option is no token", () => { + for (const version of VERSIONS) { + expect(tokenFromSetup(new SetupOptions(), version)).toBeUndefined(); + } +}); + +test("an empty value is a token", () => { + for (const version of VERSIONS) { + const params = structure(version, [0x3n, TOKEN_OUT_OF_BAND]); + expect(tokenFromSetup(params, version)).toEqual({ kind: TOKEN_OUT_OF_BAND, value: new Uint8Array() }); + } +}); + +/** We advertise no cache, so a registration is the draft's own USE_VALUE. */ +test("REGISTER is a value", () => { + for (const version of VERSIONS) { + const params = structure(version, [0x1n, 7n, TOKEN.kind], TOKEN.value); + expect(tokenFromSetup(params, version)).toEqual(TOKEN); + } +}); + +test("an alias reference is a protocol violation", () => { + for (const version of VERSIONS) { + for (const aliasType of [0x0n, 0x2n]) { + const params = structure(version, [aliasType, 7n]); + expect(sessionCode(() => tokenFromSetup(params, version))).toBe(SessionCode.ProtocolViolation); + } + } +}); + +test("an undecodable structure is a formatting error", () => { + for (const version of VERSIONS) { + for (const fields of [[], [0x3n], [0x1n], [0x1n, 7n], [0x4n, 0n]]) { + const params = structure(version, fields); + expect(sessionCode(() => tokenFromSetup(params, version))).toBe(SessionCode.KeyValueFormatting); + } + } +}); + +/** One credential per connection: a second token is refused, not unioned or dropped. */ +test("two tokens are refused", async () => { + for (const version of VERSIONS) { + const value = [...varint(0x3n, version), ...varint(TOKEN_OUT_OF_BAND, version)]; + const key = SetupOption.AuthorizationToken; + // Delta-encoded from draft-16, so the repeat is a delta of zero. + const repeat = version === Version.DRAFT_14 || version === Version.DRAFT_15 ? key : 0n; + const count = leadingOnes(version) ? [] : [...varint(2n, version)]; + const entry = (k: bigint) => [...varint(k, version), ...varint(BigInt(value.length), version), ...value]; + const bytes = new Uint8Array([...count, ...entry(key), ...entry(repeat)]); + + await expect(SetupOptions.decode(new Reader(undefined, bytes, version), version)).rejects.toThrow( + /duplicate parameter/, + ); + } +}); diff --git a/js/net/src/ietf/token.ts b/js/net/src/ietf/token.ts new file mode 100644 index 0000000000..818466696f --- /dev/null +++ b/js/net/src/ietf/token.ts @@ -0,0 +1,119 @@ +import { SessionCode, SessionError } from "../error.ts"; +import * as Varint from "../varint.ts"; +import { SetupOption, type SetupOptions } from "./parameters.ts"; +import { type IetfVersion, Version } from "./version.ts"; + +/** + * The `AUTHORIZATION TOKEN` Setup Option (draft-ietf-moq-transport-21 section 9.1.4). + * + * The value is the Token structure of section 8.9: an Alias Type, then fields that type + * selects. We advertise no `MAX_AUTH_TOKEN_CACHE_SIZE`, so its default of 0 means no alias + * is ever registered and every token arrives by value. + * + * Mirrors `rs/moq-net/src/ietf/token.rs`. + * + * @module + * @internal + */ + +/** Retire a registered alias. */ +const DELETE = 0x0n; +/** Register an alias for this type and value, then use them. */ +const REGISTER = 0x1n; +/** Use the type and value a registered alias names. */ +const USE_ALIAS = 0x2n; +/** Use the type and value carried inline. */ +const USE_VALUE = 0x3n; + +/** + * A credential presented in a SETUP's `AUTHORIZATION TOKEN` option. + * + * @internal + */ +export interface Token { + /** The wire Token Type, naming how `value` is encoded. */ + kind: bigint; + /** The token itself. */ + value: Uint8Array; +} + +/** + * Token Type 0: a format the endpoints agreed on out of band, such as a JWT. + * + * @internal + */ +export const TOKEN_OUT_OF_BAND = 0x0n; + +/** + * Token Type 1: a Common Access Token (draft-ietf-moq-c4m). + * + * @internal + */ +export const TOKEN_CAT = 0x1n; + +/** Draft-17 replaced QUIC's two-bit-length varint with a leading-ones one. */ +function leadingOnes(version: IetfVersion): boolean { + return version !== Version.DRAFT_14 && version !== Version.DRAFT_15 && version !== Version.DRAFT_16; +} + +/** + * The token the peer's SETUP presented, if any. + * + * A second token is already refused as a duplicate option by {@link SetupOptions}: one + * credential per connection. + * + * @throws {SessionError} `ProtocolViolation` for an alias reference, which nothing before + * SETUP could have registered, or `KeyValueFormatting` for a structure that cannot be decoded. + * @internal + */ +export function tokenFromSetup(params: SetupOptions, version: IetfVersion): Token | undefined { + const raw = params.getBytes(SetupOption.AuthorizationToken); + if (raw === undefined) return undefined; + + // Section 8.9: a structure that cannot be decoded closes with KEY_VALUE_FORMATTING_ERROR. + const malformed = (cause?: unknown) => + new SessionError(SessionCode.KeyValueFormatting, { cause, reason: "malformed AUTHORIZATION TOKEN" }); + const unvarint = (buf: Uint8Array): [bigint, Uint8Array] => { + try { + return leadingOnes(version) ? Varint.decodeLeadingOnes(buf) : Varint.decodeBigInt(buf); + } catch (err) { + throw malformed(err); + } + }; + + let [aliasType, rest] = unvarint(raw); + switch (aliasType) { + case USE_VALUE: + break; + // With no cache, section 9.1.4 treats a registration as a value; the alias is unused. + case REGISTER: + [, rest] = unvarint(rest); + break; + // Section 9.1.4: nothing can have been registered before SETUP. + case DELETE: + case USE_ALIAS: + throw new SessionError(SessionCode.ProtocolViolation, { reason: "AUTHORIZATION TOKEN alias in SETUP" }); + default: + throw malformed(); + } + + const [kind, value] = unvarint(rest); + return { kind, value: value.slice() }; +} + +/** + * Present `token` in our SETUP, by value. + * + * @internal + */ +export function tokenIntoSetup(params: SetupOptions, token: Token, version: IetfVersion) { + const varint = (v: bigint) => (leadingOnes(version) ? Varint.encodeLeadingOnes(v) : Varint.encode(v)); + const aliasType = varint(USE_VALUE); + const kind = varint(token.kind); + + const out = new Uint8Array(aliasType.length + kind.length + token.value.length); + out.set(aliasType, 0); + out.set(kind, aliasType.length); + out.set(token.value, aliasType.length + kind.length); + params.setBytes(SetupOption.AuthorizationToken, out); +} diff --git a/quest/m1/README.md b/quest/m1/README.md index b97087b518..c896e84725 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -46,7 +46,6 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [Wildcard](/quest/m1/wildcard/README.md) - a relay resolves subscriptions against advertised prefixes, a service claims the prefix it could serve and refuses the rest instead of enumerating broadcasts, and the browser player treats a covering claim as availability - [Tooling](/quest/m1/tooling/README.md) - justfiles become a one-line menu over `sh/`, one impact map scopes CI, and every workflow step runs a recipe - [Path patterns](/quest/m1/path-patterns.md) - one matcher for every predicate over broadcast paths: tokens, origins, interest -- [Setup token](/quest/m1/setup-token.md) - a moq-transport SETUP `AUTHORIZATION TOKEN` reaches the accepted handshake and the relay's auth request, so a verifier can run on it - [In-band auth](/quest/m1/auth/README.md) - a session tells its peer what it may publish and subscribe to, unions tokens presented in band, and fails loud on an out-of-scope publish - [Tests under load](/quest/m1/test-flakes.md) - three tests that time out or run out of file descriptors under `just check` are fixed at the cause - [Decoded frame ownership](/quest/m1/decoded-frames.md) - retain moq-video Frames across bindings, with native views or CPU conversion as needed diff --git a/quest/m1/auth/request-token.md b/quest/m1/auth/request-token.md index c6125f8295..34f77640e1 100644 --- a/quest/m1/auth/request-token.md +++ b/quest/m1/auth/request-token.md @@ -16,8 +16,8 @@ on the unknown key, and the legacy drafts silently ignore it. ## Plan -- Decode with the [Setup token](/quest/m1/setup-token.md) structure and - rules: `USE_VALUE` yields the token, `REGISTER` is a value since we +- Decode with the SETUP option's structure and rules + (`rs/moq-net/src/ietf/token.rs`, `js/net/src/ietf/token.ts`): `USE_VALUE` yields the token, `REGISTER` is a value since we advertise no `MAX_AUTH_TOKEN_CACHE_SIZE`, and `DELETE` or `USE_ALIAS` closes with `PROTOCOL_VIOLATION`. Both decoder families change: the strict `decode_params!` path, which rejects the key today, and the generic KVP @@ -64,6 +64,5 @@ to). Wire: none new; the parameter already exists in every supported draft. ## Required -- [Setup token](/quest/m1/setup-token.md) - supplies the token decoder - [Relay tokens](/quest/m1/auth/relay-refresh.md) - supplies the per-token lease and `Client::attach` path each request uses diff --git a/quest/m1/auth/token-in-band.md b/quest/m1/auth/token-in-band.md index 2c38ad0345..ee68cf6fb7 100644 --- a/quest/m1/auth/token-in-band.md +++ b/quest/m1/auth/token-in-band.md @@ -43,7 +43,7 @@ AUTH can carry the full grant once the pattern-interest prerequisite lands. stream; every other configured token gets its own AUTH stream on an AUTH-capable session, so no token is ever granted twice. On moq-transport the first token also rides the AUTHORIZATION TOKEN setup option - (`ParameterBytes::AuthorizationToken`, `USE_VALUE`, token type 0), which + (`ietf::token::into_setup`, `USE_VALUE`, token type 0), which scopes at accept the way the URL does. - The relay admits on the URL, then widens. An anonymous connection today is admitted with the public grant when one is configured and refused @@ -77,5 +77,3 @@ Additive. token setters sit beside - [moq-transport](/quest/m1/auth/moq-transport.md) - supplies the IETF AUTH exchange the setup-option token pairs with -- [Setup token](/quest/m1/setup-token.md) - supplies `setup::Token` and the - setup-option encoder diff --git a/quest/m1/setup-token.md b/quest/m1/setup-token.md deleted file mode 100644 index f90d4f5d97..0000000000 --- a/quest/m1/setup-token.md +++ /dev/null @@ -1,67 +0,0 @@ -# [M] The SETUP AUTHORIZATION TOKEN option reaches the verifier - -## Goal - -A moq-transport peer's SETUP `AUTHORIZATION TOKEN` option (key `0x03`, -draft-14 through the newest supported draft) is decoded by `rs/moq-net` into -a token type and value, exposed on the accepted handshake so an app can run -its own verifier, and forwarded by the relay in `moq_auth::Request` as -`token`, so an auth server can verify it. Today the option is stored as -opaque bytes and ignored. - -Boundaries: SETUP only. The same parameter on SUBSCRIBE, REQUEST_UPDATE, and -every other request is [Request tokens](/quest/m1/auth/request-token.md): a -fallback that authorizes only that request. No client configuration: [Token in -band](/quest/m1/auth/token-in-band.md) owns presenting one. - -## Plan - -- `moq_net::setup::Token { kind: u64, value: Vec }`, `kind` being the - wire Token Type: `Token::OUT_OF_BAND` is `0x0` and `Token::CAT` is the - `0x01` c4m-01 registers. `setup` becomes a public module exporting only - `Token`; the SETUP wire types stay crate-private. Decode the draft-21 - section 8.9 structure: Alias Type `USE_VALUE` yields the token; `REGISTER` - is treated as `USE_VALUE` because we advertise no - `MAX_AUTH_TOKEN_CACHE_SIZE` (the default of 0 makes that the draft's own - rule), so no alias state exists; `DELETE` or `USE_ALIAS` in SETUP closes - with `PROTOCOL_VIOLATION`; an undecodable structure closes with - `KEY_VALUE_FORMATTING_ERROR`. More than one token in SETUP is refused: one - credential per connection is the shape the contract has. -- Encode side: `Parameters` learns to write a `USE_VALUE` token, so [Token - in band](/quest/m1/auth/token-in-band.md) and - [Present](/quest/m2/cat/present.md) have nothing to add on the wire. -- `moq_net::server::Handshake::token() -> Option<&setup::Token>` beside - `path()` and `role()`, carried through the `Legacy` (draft-14 to 16) and - `PeerSetup` (draft-17+) paths; `moq_tokio::server::Request::token()` - forwards it; lite sessions return `None`. -- `moq_auth::Request.token: Option`, - serialized with `serde_with` base64 like the rest of the request. The relay - fills it in `request_for` in `rs/moq-relay/src/auth.rs`, beside the URL - query it already forwards. `moq auth serve` treats kind `0x0` as the JWT - the deployment negotiated out of band, verified exactly like `?jwt=`, and - refuses any other kind naming the code until - [Verify](/quest/m2/cat/verify.md) teaches it `0x01`. A request carrying - both a SETUP token and a `jwt` query is refused naming both. -- `js/net/src/ietf/parameters.ts` mirrors the decode and encode rules. No JS - accept-side API: nothing in `js/net` authorizes an IETF session. -- Docs: `doc/concept` on the IETF binding gains the option; - `doc/lib/rs/moq-auth.md` gains the request field; `doc/bin/relay/auth.md` - says the relay forwards the option and `moq auth serve` verifies a type-0 - token like `?jwt=`. -- Tests: decode and encode round trips for every alias type on every draft - in Rust and JS; `DELETE`/`USE_ALIAS` in SETUP close the session; `REGISTER` - is accepted as a value; two tokens in SETUP are refused; the handshake - exposes the bytes on one legacy and one draft-17+ session and a lite - session reports none; the relay forwards the bytes to a wiremock auth - server byte for byte; `moq auth serve` admits a type-0 JWT, refuses an - unknown kind, and refuses a token plus `?jwt=`. - -Public API: additive on `moq-net`, `moq-tokio`, `moq-auth`, and `js/net`. -Wire: none new; the option already exists in every supported draft. - -## Related - -- [Token in band](/quest/m1/auth/token-in-band.md) - writes the same option - from the client side with the shared `setup::Token` -- [Common Access Tokens](/quest/m2/cat/README.md) - verifies and presents a - CAT through this option diff --git a/quest/m2/cat/README.md b/quest/m2/cat/README.md index 8460cd1520..4c29c9c4c5 100644 --- a/quest/m2/cat/README.md +++ b/quest/m2/cat/README.md @@ -41,9 +41,9 @@ Boundaries decided while planning: ## Plan -Order: the wire first so a token reaches the auth server, which is the -standalone [Setup token](/quest/m1/setup-token.md) quest; verification; -then our clients present one. Everything rides `moq_auth::Request` and +Order: the SETUP option already reaches the auth server as +`moq_auth::Request.token`; verification comes first, then our clients present +one. Everything rides `moq_auth::Request` and `moq auth serve`, which shipped on dev. The JWT types sit at the crate root; the verify quest moves them under `moq_auth::jwt` so `cat` is a sibling module rather than a set of prefixed names. @@ -56,11 +56,6 @@ module rather than a set of prefixed names. - [Present](/quest/m2/cat/present.md) - a CAT is one kind of configured token, riding the SETUP option the in-band token quest already writes -## Required - -- [Setup token](/quest/m1/setup-token.md) - the SETUP option reaches - `moq_auth::Request` as `token` - ## Related - [In-band auth](/quest/m1/auth/README.md) - credentials presented after diff --git a/quest/m2/cat/present.md b/quest/m2/cat/present.md index cb318d20e1..7a2cf3d821 100644 --- a/quest/m2/cat/present.md +++ b/quest/m2/cat/present.md @@ -34,8 +34,6 @@ Public API: additive on `moq-tokio` and `js/net`. Wire: none. ## Required -- [Setup token](/quest/m1/setup-token.md) - the server-side exposure - and the shared `setup::Token` - [Verify](/quest/m2/cat/verify.md) - the server that admits the token the end-to-end test presents - [Token in band](/quest/m1/auth/token-in-band.md) - the token diff --git a/quest/m2/cat/verify.md b/quest/m2/cat/verify.md index 29a03c3126..c77c2c532e 100644 --- a/quest/m2/cat/verify.md +++ b/quest/m2/cat/verify.md @@ -82,8 +82,3 @@ rather than a set of prefixed names; `@moq/auth` stays flat. Public API: `moq_auth::cat` new, `moq auth serve` and `moq auth sign|verify` gain flags. Wire: none. - -## Required - -- [Setup token](/quest/m1/setup-token.md) - the token reaches the - server's request diff --git a/rs/moq-auth/src/request.rs b/rs/moq-auth/src/request.rs index 99efa583db..59628467da 100644 --- a/rs/moq-auth/src/request.rs +++ b/rs/moq-auth/src/request.rs @@ -1,4 +1,6 @@ use serde::{Deserialize, Serialize}; +use serde_with::base64::{Base64, UrlSafe}; +use serde_with::formats::Unpadded; use serde_with::{DurationSecondsWithFrac, TimestampSeconds, serde_as}; use std::net::SocketAddr; use std::time::{Duration, SystemTime}; @@ -8,8 +10,8 @@ use crate::lease::Reason; /// Everything a relay knows about a session, sent to the auth server on every event. /// /// Nothing is parsed on the relay's behalf: the server keys policy on the raw -/// [`path`](Self::path) and [`query`](Self::query), so no query parameter is special -/// and a credential can be whatever the server understands. The same shape carries +/// [`path`](Self::path), [`query`](Self::query), and [`token`](Self::token), so no +/// query parameter is special and a credential can be whatever the server understands. The same shape carries /// every [`Event`]; an `end` adds what the session did. #[serde_with::skip_serializing_none] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -46,6 +48,9 @@ pub struct Request { /// The raw query string, without the leading `?`. pub query: Option, + /// The credential a moq-transport client presented in its SETUP. + pub token: Option, + /// The direction the client declared at SETUP; absent means both. pub role: Option, @@ -73,12 +78,31 @@ impl Request { alpn: None, path: path.into(), query: None, + token: None, role: None, tls: None, } } } +/// A credential from a moq-transport SETUP's `AUTHORIZATION TOKEN` option, unparsed. +#[serde_as] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Token { + /// The moq-transport Token Type, naming how [`value`](Self::value) is encoded. + pub kind: u64, + /// The token bytes, base64url without padding on the wire. + #[serde_as(as = "Base64")] + pub value: Vec, +} + +impl Token { + /// Token Type 0: a format negotiated out of band; `moq auth serve` reads it as a JWT. + pub const OUT_OF_BAND: u64 = 0x0; + /// Token Type 1: a Common Access Token. + pub const CAT: u64 = 0x1; +} + /// The lifecycle moment a [`Request`] reports. #[serde_as] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -259,6 +283,34 @@ mod tests { ); } + /// The exact bytes `js/auth/src/contract.test.ts` parses: the value is base64url, so + /// bytes that are not text survive the JSON unchanged. + #[test] + fn a_setup_token_serializes_as_base64url() { + let mut request = Request::new("relay-1", Transport::Quic, "/demo/room"); + request.id = "00ff".into(); + request.token = Some(Token { + kind: Token::CAT, + value: vec![0x00, 0xfb, 0xff], + }); + let json = serde_json::to_string(&request).unwrap(); + assert_eq!( + json, + r#"{"id":"00ff","event":"connect","node":"relay-1","transport":"quic","path":"/demo/room","token":{"kind":1,"value":"APv_"}}"# + ); + assert_eq!(serde_json::from_str::(&json).unwrap(), request); + + // `js/auth` refuses the same malformed values. + for value in ["A", "AB", "APv_A", "AP+/"] { + let json = format!(r#"{{"kind":0,"value":"{value}"}}"#); + assert!(serde_json::from_str::(&json).is_err(), "{value}"); + } + for value in ["", "AA", "AAA", "AAAA", "AQ", "AAE"] { + let json = format!(r#"{{"kind":0,"value":"{value}"}}"#); + assert!(serde_json::from_str::(&json).is_ok(), "{value}"); + } + } + #[test] fn a_unix_session_has_no_addresses() { let request = Request::new("relay-1", Transport::Unix, ""); diff --git a/rs/moq-auth/src/serve.rs b/rs/moq-auth/src/serve.rs index 247381d1af..96e2de12bf 100644 --- a/rs/moq-auth/src/serve.rs +++ b/rs/moq-auth/src/serve.rs @@ -18,7 +18,7 @@ use axum::routing::post; use axum::{Json, Router}; use tokio::time::Instant; -use crate::{Event, Grant, Key, KeyId, Permissions, Request}; +use crate::{Event, Grant, Key, KeyId, Permissions, Request, Token}; /// Where the signing keys a `jwt` is verified against come from. Read per request, /// so a rotated file takes effect without a restart. @@ -48,8 +48,12 @@ pub struct Limits { } /// The decisions the server answers with, evaluated in order and stopping at the -/// first that applies: a `jwt` in the query, then a verified certificate, then the -/// anonymous rules. A malformed or expired token is a refusal, never a fall through. +/// first that applies: a JWT, then a verified certificate, then the anonymous rules. +/// A malformed or expired token is a refusal, never a fall through. +/// +/// The JWT is the `jwt` query parameter or a moq-transport SETUP token of type 0 +/// ([`Token::OUT_OF_BAND`]), verified alike. A session presenting both is refused, +/// as is a SETUP token of any other type. /// /// `#[non_exhaustive]`, so start from [`Policy::default`] and set the fields. #[derive(Clone, Debug)] @@ -110,12 +114,16 @@ pub enum Refusal { TokenLimit, #[error("too many live sessions from this address")] RemoteLimit, + #[error("both a SETUP token and a `jwt` query were presented; present one")] + TwoTokens, + #[error("SETUP token type {0:#x} is not supported; only type 0 (a JWT) is")] + UnsupportedToken(u64), } impl Policy { /// Decide `request` by the policy alone, ignoring session limits. pub async fn decide(&self, request: &Request) -> Result { - let (permissions, expires) = if let Some(jwt) = token(request) { + let (permissions, expires) = if let Some(jwt) = jwt(request)? { let key = self.key(jwt).await?; let claims = key.verify(jwt).map_err(|err| Refusal::InvalidToken(err.to_string()))?; let permissions = claims.authorize(&request.path).map_err(|err| match err { @@ -161,9 +169,21 @@ impl Policy { } } +/// The JWT the request presents: its SETUP token, or else its `jwt` query parameter. +fn jwt(request: &Request) -> Result, Refusal> { + match (&request.token, query_jwt(request)) { + (Some(_), Some(_)) => Err(Refusal::TwoTokens), + (Some(token), None) if token.kind == Token::OUT_OF_BAND => std::str::from_utf8(&token.value) + .map(Some) + .map_err(|_| Refusal::InvalidToken("the SETUP token is not UTF-8".into())), + (Some(token), None) => Err(Refusal::UnsupportedToken(token.kind)), + (None, jwt) => Ok(jwt), + } +} + /// The `jwt` query parameter, when the request carries a non-empty one. The last one /// wins, as it did on the relay, so a client that appends a fresh token is believed. -fn token(request: &Request) -> Option<&str> { +fn query_jwt(request: &Request) -> Option<&str> { let query = request.query.as_deref()?; // Borrow rather than decode: a JWT is base64url and never needs unescaping. query @@ -204,7 +224,8 @@ impl Sessions { slot.seen = Instant::now(); return Ok(()); } - let token = token(request).map(hash); + // Decided before counting, so the credential is already known to be one JWT. + let token = jwt(request).ok().flatten().map(hash); let remote = remote(request); if let (Some(cap), Some(token)) = (limits.token, token) && self.slots.values().filter(|slot| slot.token == Some(token)).count() >= cap @@ -232,7 +253,7 @@ impl Sessions { /// which survivor to revoke would be an accident of arrival order. fn revalidate(&mut self, request: &Request) { let slot = self.slots.entry(request.id.clone()).or_insert_with(|| Slot { - token: token(request).map(hash), + token: jwt(request).ok().flatten().map(hash), remote: remote(request), seen: Instant::now(), }); @@ -532,6 +553,73 @@ mod tests { assert!(policy.decide(&with_token(request("/demo"), &jwt)).await.is_ok()); } + fn with_setup_token(mut request: Request, kind: u64, value: &str) -> Request { + request.token = Some(Token { + kind, + value: value.as_bytes().to_vec(), + }); + request + } + + /// A type-0 SETUP token is the deployment's JWT, verified exactly like `?jwt=`. + #[tokio::test] + async fn a_type_zero_setup_token_is_a_jwt() { + let (dir, key) = key_dir(); + let policy = Policy { + keys: Some(Keys::Dir(dir.path().into())), + ..Default::default() + }; + let jwt = sign(&key, "demo", &["alice/**"], &[], None); + let grant = policy + .decide(&with_setup_token(request("/demo"), Token::OUT_OF_BAND, &jwt)) + .await + .unwrap(); + assert_eq!(grant.publish, patterns(&["alice/**"])); + + // And refused like one: a stranger's key never falls through to public. + let stranger = Key::generate(Algorithm::HS256, Some(KeyId::decode("kid1").unwrap())).unwrap(); + let forged = sign(&stranger, "demo", &["**"], &[], None); + let err = policy + .decide(&with_setup_token(request("/demo"), Token::OUT_OF_BAND, &forged)) + .await + .unwrap_err(); + assert!(matches!(err, Refusal::InvalidToken(_)), "{err}"); + } + + #[tokio::test] + async fn a_setup_token_of_another_type_is_refused() { + let (dir, key) = key_dir(); + let policy = Policy { + keys: Some(Keys::Dir(dir.path().into())), + public: rules(&["**"], &["**"]), + ..Default::default() + }; + let jwt = sign(&key, "demo", &["**"], &[], None); + let err = policy + .decide(&with_setup_token(request("/demo"), Token::CAT, &jwt)) + .await + .unwrap_err(); + assert_eq!(err, Refusal::UnsupportedToken(Token::CAT)); + assert_eq!( + err.to_string(), + "SETUP token type 0x1 is not supported; only type 0 (a JWT) is" + ); + } + + /// Two credentials would leave the server guessing which one the client meant. + #[tokio::test] + async fn a_setup_token_and_a_query_jwt_are_refused() { + let (dir, key) = key_dir(); + let policy = Policy { + keys: Some(Keys::Dir(dir.path().into())), + ..Default::default() + }; + let jwt = sign(&key, "demo", &["**"], &[], None); + let request = with_setup_token(with_token(request("/demo"), &jwt), Token::OUT_OF_BAND, &jwt); + let err = policy.decide(&request).await.unwrap_err(); + assert_eq!(err, Refusal::TwoTokens); + } + #[tokio::test] async fn the_last_jwt_in_the_query_wins() { let (dir, key) = key_dir(); @@ -545,7 +633,7 @@ mod tests { // relay; a trailing empty value does not blank it out. let mut request = request("/demo"); request.query = Some(format!("a=1&jwt=stale&jwt={fresh}&jwt=")); - assert_eq!(token(&request), Some(fresh.as_str())); + assert_eq!(query_jwt(&request), Some(fresh.as_str())); assert!(policy.decide(&request).await.is_ok()); request.query = Some(format!("jwt={fresh}&jwt=stale")); @@ -553,7 +641,7 @@ mod tests { assert!(matches!(err, Refusal::InvalidToken(_)), "{err}"); request.query = Some("jwt=&b=2".into()); - assert_eq!(token(&request), None); + assert_eq!(query_jwt(&request), None); } #[tokio::test] diff --git a/rs/moq-net/src/ietf/mod.rs b/rs/moq-net/src/ietf/mod.rs index 8ae7dac1ec..9c8577b340 100644 --- a/rs/moq-net/src/ietf/mod.rs +++ b/rs/moq-net/src/ietf/mod.rs @@ -30,6 +30,7 @@ pub mod solicit; mod subscribe; mod subscribe_namespace; mod subscriber; +pub(crate) mod token; mod track; mod version; diff --git a/rs/moq-net/src/ietf/session.rs b/rs/moq-net/src/ietf/session.rs index cb0fd4538a..121f3f10e4 100644 --- a/rs/moq-net/src/ietf/session.rs +++ b/rs/moq-net/src/ietf/session.rs @@ -435,6 +435,9 @@ pub struct PeerSetup { /// The request path the peer advertised, for URL-less transports. pub path: Option, + /// The credential the peer presented in its `AUTHORIZATION TOKEN` option. + pub token: Option, + /// The Setup Options it declared (see [`cluster`] and [`solicit`]). pub declared: peer::Peer, } @@ -485,11 +488,13 @@ pub async fn accept_setup( ), None => None, }; + let token = super::token::from_setup(¶ms, version)?; let declared = peer_from_params(¶ms, version)?; return Ok(PeerSetup { stream: reader, path, + token, declared, }); } diff --git a/rs/moq-net/src/ietf/token.rs b/rs/moq-net/src/ietf/token.rs new file mode 100644 index 0000000000..129e9e17e4 --- /dev/null +++ b/rs/moq-net/src/ietf/token.rs @@ -0,0 +1,233 @@ +//! The `AUTHORIZATION TOKEN` Setup Option (draft-ietf-moq-transport-21 section 9.1.4). +//! +//! The value is the Token structure of section 8.9: an Alias Type, then fields that +//! type selects. We advertise no `MAX_AUTH_TOKEN_CACHE_SIZE`, so its default of 0 means +//! no alias is ever registered and every token arrives by value. + +use crate::{ + Error, SessionError, + coding::{Decode, Encode, EncodeError}, + setup::Token, +}; + +use super::{ParameterBytes, Parameters, Version}; + +/// Retire a registered alias. +const DELETE: u64 = 0x0; +/// Register an alias for this type and value, then use them. +const REGISTER: u64 = 0x1; +/// Use the type and value a registered alias names. +const USE_ALIAS: u64 = 0x2; +/// Use the type and value carried inline. +const USE_VALUE: u64 = 0x3; + +/// The token the peer's SETUP presented, if any. +/// +/// A second token is already refused as a duplicate option by [`Parameters`]: one +/// credential per connection. +pub fn from_setup(params: &Parameters, version: Version) -> Result, Error> { + params + .get_bytes(ParameterBytes::AuthorizationToken) + .map(|value| decode(value, version)) + .transpose() +} + +/// Present `token` in our SETUP, by value. +#[cfg_attr(not(test), expect(dead_code))] +pub fn into_setup(params: &mut Parameters, token: &Token, version: Version) -> Result<(), EncodeError> { + let mut value = Vec::new(); + USE_VALUE.encode(&mut value, version)?; + token.kind.encode(&mut value, version)?; + value.extend_from_slice(&token.value); + params.set_bytes(ParameterBytes::AuthorizationToken, value); + Ok(()) +} + +/// Decode a Token structure, refusing what a SETUP cannot carry. +fn decode(mut buf: &[u8], version: Version) -> Result { + // Section 8.9: a structure that cannot be decoded closes with KEY_VALUE_FORMATTING_ERROR. + let malformed = |_| Error::Session(SessionError::KeyValueFormatting); + + match u64::decode(&mut buf, version).map_err(malformed)? { + USE_VALUE => {} + // With no cache, section 9.1.4 treats a registration as a value; the alias is unused. + REGISTER => { + u64::decode(&mut buf, version).map_err(malformed)?; + } + // Section 9.1.4: nothing can have been registered before SETUP. + DELETE | USE_ALIAS => return Err(Error::ProtocolViolation), + _ => return Err(Error::Session(SessionError::KeyValueFormatting)), + } + + let kind = u64::decode(&mut buf, version).map_err(malformed)?; + Ok(Token { + kind, + value: buf.to_vec(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + const VERSIONS: [Version; 9] = [ + Version::Draft14, + Version::Draft15, + Version::Draft16, + Version::Draft17, + Version::Draft18, + Version::Draft19, + Version::Draft20, + Version::Draft21, + Version::Draft22, + ]; + + fn token() -> Token { + // A kind past one varint byte and a value that is not text, so neither is mistaken + // for the other and no codec can get away with assuming UTF-8. + Token { + kind: 300, + value: vec![0x00, 0xff, 0x03, 0x80, b'j'], + } + } + + /// The option as it arrives, after a trip through the SETUP parameter block. + fn received(params: &Parameters, version: Version) -> Parameters { + let mut bytes = params.encode_bytes(version).unwrap(); + Parameters::decode(&mut bytes, version).unwrap() + } + + /// A raw Token structure: the alias type then its varint fields then a value. + fn structure(version: Version, fields: &[u64], value: &[u8]) -> Parameters { + let mut raw = Vec::new(); + for field in fields { + field.encode(&mut raw, version).unwrap(); + } + raw.extend_from_slice(value); + let mut params = Parameters::default(); + params.set_bytes(ParameterBytes::AuthorizationToken, raw); + received(¶ms, version) + } + + #[test] + fn use_value_round_trips_on_every_draft() { + for version in VERSIONS { + let mut params = Parameters::default(); + into_setup(&mut params, &token(), version).unwrap(); + let params = received(¶ms, version); + assert_eq!(from_setup(¶ms, version).unwrap(), Some(token()), "{version:?}"); + } + } + + /// The same bytes `js/net/src/ietf/token.test.ts` asserts, so the two agree on the wire. + #[test] + fn the_encoding_matches_the_cross_language_vector() { + let token = Token { + kind: 300, + value: vec![0x00, 0xff], + }; + for (version, expected) in [ + (Version::Draft14, [0x03, 0x41, 0x2c, 0x00, 0xff]), + (Version::Draft17, [0x03, 0x81, 0x2c, 0x00, 0xff]), + ] { + let mut params = Parameters::default(); + into_setup(&mut params, &token, version).unwrap(); + assert_eq!( + params.get_bytes(ParameterBytes::AuthorizationToken), + Some(&expected[..]), + "{version:?}" + ); + } + } + + #[test] + fn an_empty_value_is_a_token() { + for version in VERSIONS { + let params = structure(version, &[USE_VALUE, Token::OUT_OF_BAND], &[]); + let expected = Token { + kind: Token::OUT_OF_BAND, + value: Vec::new(), + }; + assert_eq!(from_setup(¶ms, version).unwrap(), Some(expected), "{version:?}"); + } + } + + #[test] + fn absent_is_none() { + for version in VERSIONS { + assert_eq!(from_setup(&Parameters::default(), version).unwrap(), None); + } + } + + /// We advertise no cache, so a registration is the draft's own USE_VALUE. + #[test] + fn register_is_a_value() { + for version in VERSIONS { + let params = structure(version, &[REGISTER, 7, token().kind], &token().value); + assert_eq!(from_setup(¶ms, version).unwrap(), Some(token()), "{version:?}"); + } + } + + /// One credential per connection: a second token is refused, not unioned or dropped. + #[test] + fn two_tokens_are_refused() { + for version in VERSIONS { + let mut value = Vec::new(); + USE_VALUE.encode(&mut value, version).unwrap(); + Token::OUT_OF_BAND.encode(&mut value, version).unwrap(); + + let key = u64::from(ParameterBytes::AuthorizationToken); + let (count, keys): (Option, [u64; 2]) = match version { + Version::Draft14 | Version::Draft15 => (Some(2), [key, key]), + // Delta-encoded from draft-16, so the repeat is a delta of zero. + Version::Draft16 => (Some(2), [key, 0]), + _ => (None, [key, 0]), + }; + let mut raw = Vec::new(); + if let Some(count) = count { + count.encode(&mut raw, version).unwrap(); + } + for key in keys { + key.encode(&mut raw, version).unwrap(); + value.encode(&mut raw, version).unwrap(); + } + + let err = Parameters::decode(&mut raw.as_slice(), version).unwrap_err(); + assert!(matches!(err, crate::DecodeError::Duplicate), "{version:?}: {err:?}"); + } + } + + #[test] + fn an_alias_reference_is_a_protocol_violation() { + for version in VERSIONS { + for alias_type in [DELETE, USE_ALIAS] { + let params = structure(version, &[alias_type, 7], &[]); + let err = from_setup(¶ms, version).unwrap_err(); + assert!( + matches!(err, Error::ProtocolViolation), + "{version:?} {alias_type}: {err:?}" + ); + } + } + } + + #[test] + fn an_undecodable_structure_is_a_formatting_error() { + for version in VERSIONS { + for (fields, why) in [ + (&[][..], "no alias type"), + (&[USE_VALUE][..], "no token type"), + (&[REGISTER][..], "no alias"), + (&[REGISTER, 7][..], "no token type after the alias"), + (&[0x4, 0][..], "an unknown alias type"), + ] { + let params = structure(version, fields, &[]); + let err = from_setup(¶ms, version).unwrap_err(); + assert!( + matches!(err, Error::Session(SessionError::KeyValueFormatting)), + "{version:?} {why}: {err:?}" + ); + } + } + } +} diff --git a/rs/moq-net/src/lib.rs b/rs/moq-net/src/lib.rs index 732ce41989..ffb5a32415 100644 --- a/rs/moq-net/src/lib.rs +++ b/rs/moq-net/src/lib.rs @@ -85,7 +85,7 @@ mod lite; mod model; pub mod path; mod recv; -mod setup; +pub mod setup; mod tail; #[cfg(test)] mod test_interop; diff --git a/rs/moq-net/src/server.rs b/rs/moq-net/src/server.rs index e26f5e632d..a6e9fd0fc9 100644 --- a/rs/moq-net/src/server.rs +++ b/rs/moq-net/src/server.rs @@ -151,7 +151,17 @@ impl Server { /// which is what drops the thread-affinity bounds: a pinned `!Send` /// transport can gate on the advertised path too. Anything but a moq-lite /// ALPN is refused with [`Error::Version`]. - pub async fn accept_request_lite(&self, now: Instant, mut session: S) -> Result, Error> + pub async fn accept_request_lite(&self, now: Instant, session: S) -> Result, Error> + where + S: crate::transport::poll::Session, + { + let mut refused = session.clone(); + self.handshake_lite(now, session) + .await + .inspect_err(|err| close(&mut refused, err)) + } + + async fn handshake_lite(&self, now: Instant, mut session: S) -> Result, Error> where S: crate::transport::poll::Session, { @@ -215,6 +225,8 @@ impl Server { path, role, origin, + // moq-lite carries no SETUP token. + token: None, assigned_hop: crate::Hop::random(), inner: Some(RequestInner { server: self.clone(), @@ -250,7 +262,22 @@ impl Server { /// /// The path is surfaced for moq-lite-05 and newer, and every moq-transport /// draft we speak; it's empty on versions with no in-band request path (lite 01-04). - pub async fn accept_request(&self, now: Instant, mut session: S) -> Result, Error> + /// + /// A SETUP that fails to parse or negotiate closes the session with the matching code, + /// so the peer learns why instead of seeing a bare disconnect. + pub async fn accept_request(&self, now: Instant, session: S) -> Result, Error> + where + S: crate::transport::poll::Boxable, + S::SendStream: MaybeSync, + S::RecvStream: MaybeSync, + { + let mut refused = session.clone(); + self.handshake(now, session) + .await + .inspect_err(|err| close(&mut refused, err)) + } + + async fn handshake(&self, now: Instant, mut session: S) -> Result, Error> where S: crate::transport::poll::Boxable, S::SendStream: MaybeSync, @@ -295,7 +322,7 @@ impl Server { // Every lite ALPN goes through the same entry point, which is also // what a `!Send` transport calls directly. Some(ALPN_LITE_07_WIP | ALPN_LITE_06 | ALPN_LITE_05 | ALPN_LITE_04 | ALPN_LITE_03) => { - return self.accept_request_lite(now, session).await; + return self.handshake_lite(now, session).await; } Some(ALPN_LITE) | None => { let supported = self.versions.filter(&NEGOTIATED.into()).ok_or(Error::Version)?; @@ -319,7 +346,7 @@ impl Server { // Pull the request path and max request ID out now (IETF only) so `ok()` // doesn't re-decode the consumed parameters. moq-transport carries the path // in its SETUP just like lite-05. - let (path, request_id_max, peer_declared) = match version { + let (path, token, request_id_max, peer_declared) = match version { Version::Ietf(v) => { let params = ietf::Parameters::decode(&mut client.parameters, v)?; let path = match params.get_bytes(ietf::ParameterBytes::Path) { @@ -330,6 +357,7 @@ impl Server { ), None => None, }; + let token = ietf::token::from_setup(¶ms, v)?; let request_id_max = params .get_varint(ietf::ParameterVarInt::MaxRequestId) .map(ietf::RequestId); @@ -338,15 +366,16 @@ impl Server { hidden: ietf::hidden::from_setup(¶ms, v), ..Default::default() }; - (path, request_id_max, peer_declared) + (path, token, request_id_max, peer_declared) } - Version::Lite(_) => (None, None, ietf::peer::Peer::default()), + Version::Lite(_) => (None, None, None, ietf::peer::Peer::default()), }; Ok(Handshake { path, role: None, origin: None, + token, assigned_hop: crate::Hop::random(), inner: Some(RequestInner { server: self.clone(), @@ -382,6 +411,7 @@ impl Server { // A moq-transport peer only has an identity if it negotiated the MoQ // Cluster extension and declared a non-zero Hop ID. origin: peer_setup.declared.cluster.hop.filter(|h| *h != crate::Hop::UNKNOWN), + token: peer_setup.token.clone(), assigned_hop: crate::Hop::random(), inner: Some(RequestInner { server: self.clone(), @@ -407,6 +437,7 @@ pub struct Handshake { path: Option, role: Option, origin: Option, + token: Option, /// The identity this session's routes are stamped with when the peer declares none /// on the wire. Fresh per request unless the caller overrides it /// ([`Handshake::with_peer_hop`]). @@ -516,9 +547,8 @@ where .maybe_boxed() } - fn close(self: Box, err: Error) { - let mut session = self.session; - session.close(SessionError::from(&err).to_code(), &err.to_string()); + fn close(mut self: Box, err: Error) { + close(&mut self.session, &err); } } @@ -619,9 +649,8 @@ where .maybe_boxed() } - fn close(self: Box, err: Error) { - let mut session = self.session; - session.close(SessionError::from(&err).to_code(), &err.to_string()); + fn close(mut self: Box, err: Error) { + close(&mut self.session, &err); } } @@ -663,6 +692,14 @@ where self.origin } + /// The credential the client presented in its SETUP's `AUTHORIZATION TOKEN` option. + /// + /// Only moq-transport carries one, so moq-lite sessions return `None`. The transport + /// has not verified it: authorize on it the way you would a URL token. + pub fn token(&self) -> Option<&setup::Token> { + self.token.as_ref() + } + /// Publish to the connected client. Overrides any value from the [`Server`] /// builder; typically set after inspecting [`path`](Self::path). pub fn with_publisher(mut self, publish: impl Consume) -> Self { @@ -742,10 +779,15 @@ impl RequestInner { PausedHandshake::LiteSetup { session, .. } => session, PausedHandshake::Boxed(paused) => return paused.close(err), }; - session.close(SessionError::from(&err).to_code(), &err.to_string()); + close(&mut session, &err); } } +/// Close `session` with `err`'s wire code. +fn close(session: &mut S, err: &Error) { + session.close(SessionError::from(err).to_code(), &err.to_string()); +} + impl Drop for Handshake { // A dropped request would otherwise leave the client hanging until its idle // timeout: it already sent SETUP and is waiting on a response. Reject loudly. @@ -789,12 +831,15 @@ mod tests { } } - /// A session that replays a queue of unidirectional streams (each a `Vec`) in - /// order from `accept_uni`; everything else is inert. + /// A session that replays a queue of streams (each a `Vec`) in order from + /// `accept_uni` and `accept_bi`, and records the code it was closed with; everything + /// else is inert. #[derive(Clone)] struct FakeSession { protocol: Option<&'static str>, uni: Arc>>>, + bi: Arc>>>, + closed: Arc>>, } impl FakeSession { @@ -802,8 +847,19 @@ mod tests { Self { protocol: Some(protocol), uni: Arc::new(Mutex::new(uni.into_iter().collect())), + bi: Default::default(), + closed: Default::default(), } } + + fn with_bi(self, bi: Vec) -> Self { + self.bi.lock().unwrap().push_back(bi); + self + } + + fn closed(&self) -> Option { + *self.closed.lock().unwrap() + } } impl web_transport_trait::poll::Session for FakeSession { @@ -824,7 +880,10 @@ mod tests { &mut self, _cx: &mut std::task::Context<'_>, ) -> std::task::Poll> { - std::task::Poll::Pending + match self.bi.lock().unwrap().pop_front() { + Some(data) => std::task::Poll::Ready(Ok((FakeSend, FakeRecv { data: data.into() }))), + None => std::task::Poll::Pending, + } } fn poll_open_bi( &mut self, @@ -857,7 +916,9 @@ mod tests { fn protocol(&self) -> Option<&str> { self.protocol } - fn close(&mut self, _code: u32, _reason: &str) {} + fn close(&mut self, code: u32, _reason: &str) { + self.closed.lock().unwrap().get_or_insert(code); + } fn poll_closed(&mut self, _cx: &mut std::task::Context<'_>) -> std::task::Poll { std::task::Poll::Pending } @@ -936,6 +997,10 @@ mod tests { if let Some(path) = path { params.set_bytes(ietf::ParameterBytes::Path, path.as_bytes().to_vec()); } + ietf_setup_with(version, params) + } + + fn ietf_setup_with(version: ietf::Version, params: ietf::Parameters) -> Vec { let parameters = params.encode_bytes(version).unwrap(); let mut buf = Vec::new(); @@ -945,6 +1010,91 @@ mod tests { buf } + /// Encode a draft 14-16 CLIENT_SETUP, sent on the control bidi stream. + fn legacy_setup(version: ietf::Version, params: ietf::Parameters) -> Vec { + let mut buf = Vec::new(); + setup::Client { + versions: crate::coding::Versions::from([crate::Version::Ietf(version).into()]), + parameters: params.encode_bytes(version).unwrap(), + } + .encode(&mut buf, crate::Version::Ietf(version)) + .unwrap(); + buf + } + + fn setup_token() -> setup::Token { + setup::Token { + kind: setup::Token::OUT_OF_BAND, + value: vec![0x00, 0xff, b'j', b'w', b't'], + } + } + + fn token_params(version: ietf::Version) -> ietf::Parameters { + let mut params = ietf::Parameters::default(); + ietf::token::into_setup(&mut params, &setup_token(), version).unwrap(); + params + } + + #[tokio::test(start_paused = true)] + async fn accept_request_exposes_the_setup_token() { + let modern = FakeSession::new( + ALPN_19, + [ietf_setup_with( + ietf::Version::Draft19, + token_params(ietf::Version::Draft19), + )], + ); + let legacy = FakeSession::new(ALPN_16, []).with_bi(legacy_setup( + ietf::Version::Draft16, + token_params(ietf::Version::Draft16), + )); + for (name, session) in [("draft-19", modern), ("draft-16", legacy)] { + let request = Server::new() + .accept_request(tokio::time::Instant::now().into_std(), session) + .await + .unwrap(); + assert_eq!(request.token(), Some(&setup_token()), "{name}"); + } + } + + #[tokio::test(start_paused = true)] + async fn accept_request_without_a_token_reports_none() { + let ietf = FakeSession::new(ALPN_19, [ietf_setup(ietf::Version::Draft19, None)]); + let lite = FakeSession::new(ALPN_LITE_05, [lite05_setup(None, None, None)]); + for (name, session) in [("draft-19", ietf), ("lite-05", lite)] { + let request = Server::new() + .accept_request(tokio::time::Instant::now().into_std(), session) + .await + .unwrap(); + assert_eq!(request.token(), None, "{name}"); + } + } + + /// A SETUP the server refuses closes the session with the code naming why, on both + /// the draft-17+ uni stream and the draft 14-16 bidi stream. + #[tokio::test(start_paused = true)] + async fn a_refused_setup_token_closes_with_its_code() { + let delete = [0x0, 0x7]; // DELETE alias 7 + let truncated = [0x3]; // USE_VALUE with no Token Type + for (raw, code) in [ + (&delete[..], SessionError::ProtocolViolation), + (&truncated[..], SessionError::KeyValueFormatting), + ] { + let mut params = ietf::Parameters::default(); + params.set_bytes(ietf::ParameterBytes::AuthorizationToken, raw.to_vec()); + + let modern = FakeSession::new(ALPN_19, [ietf_setup_with(ietf::Version::Draft19, params.clone())]); + let legacy = FakeSession::new(ALPN_16, []).with_bi(legacy_setup(ietf::Version::Draft16, params)); + for (name, session) in [("draft-19", modern), ("draft-16", legacy)] { + let result = Server::new() + .accept_request(tokio::time::Instant::now().into_std(), session.clone()) + .await; + assert!(result.is_err(), "{name}"); + assert_eq!(session.closed(), Some(code.to_code()), "{name} {code}"); + } + } + } + #[tokio::test(start_paused = true)] async fn accept_request_reads_ietf_path() { // Every draft-17+ version gates on the SETUP stream before starting, so the @@ -1075,6 +1225,7 @@ mod tests { path: None, role: None, origin: None, + token: None, assigned_hop: Hop::random(), inner: Some(RequestInner { server: Server::new().with_publisher(&origin), @@ -1088,6 +1239,7 @@ mod tests { Version::Ietf(version), ), path: None, + token: None, declared: ietf::peer::Peer::default(), }, })), diff --git a/rs/moq-net/src/setup.rs b/rs/moq-net/src/setup.rs index 7d0006ecf0..3a4a96cba0 100644 --- a/rs/moq-net/src/setup.rs +++ b/rs/moq-net/src/setup.rs @@ -1,3 +1,7 @@ +//! The SETUP exchange, and the credential a peer may present in it. +//! +//! Only [`Token`] is public; the SETUP messages themselves are wire internals. + use bytes::Bytes; use crate::{ @@ -12,9 +16,27 @@ const SERVER_SETUP: u8 = 0x21; /// Draft-17 unified SETUP message type (varint 0x2F00) pub(crate) const SETUP_V17: u64 = 0x2F00; +/// A credential a moq-transport peer presented in its SETUP's `AUTHORIZATION TOKEN` option. +/// +/// The transport never reads the bytes; verifying them is the application's job. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Token { + /// The wire Token Type, naming how [`value`](Self::value) is encoded. + pub kind: u64, + /// The token itself. + pub value: Vec, +} + +impl Token { + /// Token Type 0: a format the endpoints agreed on out of band, such as a JWT. + pub const OUT_OF_BAND: u64 = 0x0; + /// Token Type 1: a Common Access Token (draft-ietf-moq-c4m). + pub const CAT: u64 = 0x1; +} + /// Draft-17+ unified SETUP message, with the same encoding for both client and server. #[derive(Debug, Clone)] -pub struct Setup { +pub(crate) struct Setup { pub parameters: Bytes, } @@ -88,7 +110,7 @@ impl SetupVersion { /// A version-agnostic setup message sent by the client. #[derive(Debug, Clone)] -pub struct Client { +pub(crate) struct Client { /// The list of supported versions in preferred order. pub versions: coding::Versions, @@ -171,7 +193,7 @@ impl Encode for Client { /// Sent by the server in response to a client setup. #[derive(Debug, Clone)] -pub struct Server { +pub(crate) struct Server { /// The list of supported versions in preferred order. pub version: coding::Version, diff --git a/rs/moq-relay/Cargo.toml b/rs/moq-relay/Cargo.toml index 76f663270a..ea8a6ad3b2 100644 --- a/rs/moq-relay/Cargo.toml +++ b/rs/moq-relay/Cargo.toml @@ -91,6 +91,7 @@ sd-notify = { workspace = true } [dev-dependencies] moq-auth = { path = "../moq-auth", features = ["client", "serve"] } moq-shaper = { path = "../moq-shaper" } +qmux = { workspace = true, features = ["tcp"] } rand = { workspace = true } rcgen = "0.14" tempfile = { workspace = true } diff --git a/rs/moq-relay/src/auth.rs b/rs/moq-relay/src/auth.rs index 57a5316b23..bbe0261d90 100644 --- a/rs/moq-relay/src/auth.rs +++ b/rs/moq-relay/src/auth.rs @@ -473,6 +473,10 @@ pub fn request_for(auth: &Auth, request: &moq_tokio::server::Request) -> Request }; let mut out = auth.request(transport, path); out.query = request.query().map(str::to_owned); + out.token = request.token().map(|token| moq_auth::Token { + kind: token.kind, + value: token.value.clone(), + }); out.remote = request.remote_addr(); out.local = request.local_addr(); out.server_name = request diff --git a/rs/moq-relay/src/session.rs b/rs/moq-relay/src/session.rs index 016e49f1d9..da438de041 100644 --- a/rs/moq-relay/src/session.rs +++ b/rs/moq-relay/src/session.rs @@ -136,7 +136,7 @@ impl Registry { /// /// `id` and every scalar are exact. `path` is a [`Pattern`] against the dialed /// path. `remote` is an IP or CIDR with the port dropped and IPv4-mapped IPv6 -/// folded. `query` is not a field: it may carry the credential. +/// folded. `query` and `token` are not fields: they carry the credential. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct Filter { /// Session id. @@ -402,7 +402,7 @@ impl IntoResponse for Error { } /// One live session as the list route returns it: the request the server saw, -/// minus `query`, plus when it was admitted. +/// minus its credentials (`query` and `token`), plus when it was admitted. #[serde_as] #[serde_with::skip_serializing_none] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -453,7 +453,7 @@ impl View { /// `GET /sessions` body. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct List { - /// Matching sessions, `query` omitted. + /// Matching sessions, credentials omitted. pub sessions: Vec, } @@ -530,14 +530,21 @@ mod tests { } #[test] - fn list_omits_query() { + fn list_omits_credentials() { let registry = Registry::new(); - let _reg = registry.register(request("abc", "/room", "127.0.0.1:1")); + let mut session = request("abc", "/room", "127.0.0.1:1"); + session.query = Some("jwt=secret".into()); + session.token = Some(moq_auth::Token { + kind: moq_auth::Token::OUT_OF_BAND, + value: b"secret".to_vec(), + }); + let _reg = registry.register(session); let list = registry.list(&Filter::default()); assert_eq!(list.len(), 1); assert_eq!(list[0].id, "abc"); let json = serde_json::to_value(&list[0]).unwrap(); assert!(json.get("query").is_none()); + assert!(json.get("token").is_none()); } #[tokio::test] diff --git a/rs/moq-relay/tests/auth_lifetime.rs b/rs/moq-relay/tests/auth_lifetime.rs index 7d3922f23e..d5d39c72a8 100644 --- a/rs/moq-relay/tests/auth_lifetime.rs +++ b/rs/moq-relay/tests/auth_lifetime.rs @@ -348,12 +348,57 @@ async fn admits_and_reports_the_session() { assert!(connect.remote.is_some_and(|addr| addr.ip().is_loopback())); assert!(connect.local.is_some_and(|addr| addr.port() == port)); assert!(connect.tls.is_none()); + assert!(connect.token.is_none(), "moq-lite carries no SETUP token"); drop(pub_session); drop(sub_session); relay.abort(); } +/// A moq-transport client's SETUP token reaches the auth server byte for byte. +#[tokio::test] +async fn forwards_the_setup_token() { + use web_transport_trait::{RecvStream as _, SendStream as _, Session as _}; + + let script = Script::new(grant(Duration::from_secs(3600))); + let (port, relay) = spawn_relay(build_auth(script.spawn().await)).await; + + // No client presents a SETUP token yet, so write a draft-16 CLIENT_SETUP by hand. One + // parameter, AUTHORIZATION TOKEN (3), holding USE_VALUE (3), Token Type 0, then a + // value that is not text, so any lossy step on the way shows. + let value = [0x00, 0xff, 0x80, b'x']; + let token = [&[0x03, 0x00][..], &value].concat(); + let params = [&[0x01, 0x03, token.len() as u8][..], &token].concat(); + let setup = [&[0x20][..], &(params.len() as u16).to_be_bytes(), ¶ms].concat(); + + let session = qmux::tcp::Config::new(qmux::Version::QMux01) + .protocols(["moqt-16"]) + .connect(("127.0.0.1", port)) + .await + .expect("connect"); + let (mut send, mut recv) = session.open_bi().await.expect("open the control stream"); + send.write_all(&setup).await.expect("send CLIENT_SETUP"); + + // The relay answers SERVER_SETUP only once the auth server has admitted the session. + let mut reply = [0u8; 1]; + tokio::time::timeout(TIMEOUT, recv.read(&mut reply)) + .await + .expect("SERVER_SETUP timeout") + .expect("SERVER_SETUP"); + + let seen = script.seen.lock().unwrap().clone(); + let connect = seen.iter().find(|r| r.event == Event::Connect).expect("a connect"); + assert_eq!( + connect.token, + Some(moq_auth::Token { + kind: moq_auth::Token::OUT_OF_BAND, + value: value.to_vec(), + }) + ); + + relay.abort(); +} + /// A refusal at connect, a 5xx, and a garbage reply all refuse the session. #[tokio::test] async fn refusals_and_outages_refuse_at_connect() { diff --git a/rs/moq-tokio/src/server.rs b/rs/moq-tokio/src/server.rs index c06beaf474..fa498347a5 100644 --- a/rs/moq-tokio/src/server.rs +++ b/rs/moq-tokio/src/server.rs @@ -1470,6 +1470,14 @@ impl Request { request_ref!(self, r => r.peer_hop()) } + /// The credential a moq-transport client presented in its SETUP's `AUTHORIZATION + /// TOKEN` option, unverified. moq-lite sessions return `None`. + /// + /// Like [`query`](Self::query), it can hold a credential. Avoid logging it. + pub fn token(&self) -> Option<&moq_net::setup::Token> { + request_ref!(self, r => r.token()) + } + /// The client certificate chain the peer presented, if any, validated /// against a configured [`crate::tls::Listen::root`] during the handshake. ///