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
20 changes: 13 additions & 7 deletions doc/bin/relay/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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=<token>`. HMAC
The client dials `https://relay.example.com/rooms/123?jwt=<token>`. 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.
Expand Down Expand Up @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions doc/concept/standard.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions doc/lib/rs/moq-auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
31 changes: 30 additions & 1 deletion js/auth/src/contract.test.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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(() =>
Expand Down
17 changes: 15 additions & 2 deletions js/auth/src/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,17 @@ export const PeerSchema = z.object({
});
export type Peer = z.infer<typeof PeerSchema>;

/** 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()),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the full token-type range in JSON

When a peer uses a valid 62-bit Token Type above Number.MAX_SAFE_INTEGER, the Rust relay serializes the u64 as a JSON number, but JavaScript cannot preserve it and z.int() rejects unsafe integers. A TypeScript auth server therefore cannot inspect or cleanly reject such a token and may see the entire auth request fail instead; encode kind losslessly, such as with a decimal string. (Written by GPT-5.6 Sol)

Useful? React with 馃憤聽/ 馃憥.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving kind as a JSON number. Registered Token Types are small (0 and 1 today), and a type above 2^53 fails RequestSchema.parse, which refuses the session: it fails closed, never open. A decimal string would make every consumer parse the common case to handle one that has no registered meaning.

(Written by Claude Opus 5.5)

/** 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<typeof TokenSchema>;

/** 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. */
Expand Down Expand Up @@ -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. */
Expand All @@ -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", [
Expand Down
1 change: 1 addition & 0 deletions js/net/src/ietf/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
155 changes: 155 additions & 0 deletions js/net/src/ietf/token.test.ts
Original file line number Diff line number Diff line change
@@ -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<SetupOptions> {
const chunks: Uint8Array[] = [];
const writer = new Writer(
new WritableStream<Uint8Array>({
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/,
);
}
});
Loading
Loading