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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion doc/bin/relay/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,12 @@ after which it can never sign a broader token.
| `subscribe` | Patterns the bearer may subscribe to under `root`. Same rules. |
| `exp`, `iat` | Expiry and issue time. `exp` is enforced for the whole session, not just at connect. |

A token carrying the retired `put` and `get` prefix lists fails verification.
Tokens and key scopes from the older `moq-token` format still work: each `put`
and `get` prefix `p` reads as the subtree `p/**`, and `""` as `**`. When every
grant is a subtree, signing writes that older form so relays and auth servers
that predate patterns accept it too. A grant only a pattern can express (an
exact `foo`, or `*/chat`) is written as `publish`/`subscribe`, which an older
verifier refuses.

### Path matching

Expand Down
2 changes: 1 addition & 1 deletion doc/lib/rs/moq-auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ accept loop that decides in process.
- **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.
- **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; a token carrying the retired `put` and `get` prefix lists fails verification.
- **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.

```bash
Expand Down
16 changes: 10 additions & 6 deletions doc/setup/upgrade.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ error lists and rerun.
## Wire

Older protocol versions still negotiate, so relays and clients can be upgraded
in any order, apart from [re-minting tokens](#relay-and-cli) and two wire
in any order, apart from [pattern-only token grants](#relay-and-cli) and two wire
changes:

- The lite 06 ALPN is `moq-lite-06`, not `moq-lite-06-wip`. An explicit
Expand Down Expand Up @@ -66,10 +66,14 @@ Other changes to a deployment:
`anon`; write `anon/**` for the subtree. This applies to `--auth-public`,
TOML `public`, and the `[auth.public]` table, which is now
`public_subscribe` / `public_publish`.
- **Re-mint tokens.** JWT `publish` and `subscribe` claims are patterns, so a
token granting `alice` covers only `alice`; sign `alice/**` instead. Tokens
carrying the retired `put` or `get` claims fail verification, so re-mint
them when the relay and auth server upgrade.
- **Token grants are patterns.** JWT `publish` and `subscribe` claims are
patterns, so a token granting `alice` covers only `alice`; sign `alice/**`
instead. Existing `put`/`get` tokens and key scopes keep working as subtrees,
and subtree-only grants are still signed in that form, so a `moq-token`
deployment can upgrade issuers and verifiers in either order. Verifiers on
the pattern-only `moq-auth` 0.1.0/0.1.1 or `@moq/auth` 0.1.x/0.2.0 refuse
that form, so upgrade them before their issuers. Grants only a pattern can
express need an upgraded verifier.
- **mTLS admits nothing on its own.** A verified client certificate is reported
to the auth server, which grants it. `moq auth serve --mtls-publish '**' --mtls-subscribe '**'` restores the old full access for every certificate
the relay's client CA verifies, so keep that CA to cluster peers.
Expand Down Expand Up @@ -133,7 +137,7 @@ The JavaScript packages have no changelog; this list follows the breaking
PRs, so a minor rename may be missing.

- **@moq/token is @moq/auth.** `sign` / `verify` are `Key.sign` / `Key.verify`,
and claims are pattern unions (see [Re-mint tokens](#relay-and-cli)).
and claims are pattern unions (see [Token grants are patterns](#relay-and-cli)).
- **One `Connection`** (#3614, #3636). `Connection.Reload` is
`new Moq.Connection({ url })`, which pools one connection per relay. `closed`
settles only on `close()`; the error that stopped retrying is `error`.
Expand Down
33 changes: 29 additions & 4 deletions js/auth/src/claims.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { expect, test } from "bun:test";
import { authorize, type Claims, ClaimsSchema, ScopeSchema } from "./claims.ts";
import { encodeGrants } from "./wire.ts";

// These cases mirror the Rust moq-auth crate's claims::tests one-for-one, so both
// sides stay pinned to the same authorization semantics.
Expand All @@ -23,11 +24,35 @@ test("claims granting nothing are rejected, matching Rust's useless-token rule",
expect(ClaimsSchema.parse({ root: "demo", publish: ["**"] }).publish).toEqual(["**"]);
});

test("claims refuse the retired put/get prefix fields", () => {
expect(() => ClaimsSchema.parse({ root: "demo", put: ["alice"] })).toThrow();
expect(() => ClaimsSchema.parse({ root: "demo", get: "" })).toThrow();
test("claims read legacy put/get prefixes as subtrees", () => {
const claims = ClaimsSchema.parse({ root: "demo", put: ["alice", "/a//b/"], get: "" });
expect(claims.publish).toEqual(["alice/**", "a/b/**"]);
expect(claims.subscribe).toEqual(["**"]);
expect(ScopeSchema.parse({ root: "demo", put: ["room"] }).publish).toEqual(["room/**"]);
});

test("claims refuse mixed encodings and wildcard legacy prefixes", () => {
expect(() => ClaimsSchema.parse({ root: "demo", publish: ["alice"], get: [""] })).toThrow();
expect(() => ScopeSchema.parse({ root: "demo", put: ["alice"] })).toThrow();
expect(() => ClaimsSchema.parse({ root: "demo", put: [], subscribe: ["alice"] })).toThrow();
expect(() => ClaimsSchema.parse({ root: "demo", put: ["a/*"] })).toThrow();
expect(() => ScopeSchema.parse({ root: "demo", put: ["room"], publish: ["x"] })).toThrow();
// A legacy scope held only lists.
expect(() => ScopeSchema.parse({ root: "demo", put: "room" })).toThrow();
});

test("grants are written the legacy way only when faithful", () => {
expect(encodeGrants({ root: "live", publish: ["camera1/**"], subscribe: ["**"] })).toEqual({
root: "live",
put: ["camera1"],
get: [""],
});
// One grant a prefix can't say moves the whole document to patterns.
expect(encodeGrants({ root: "live", publish: ["camera1/**"], subscribe: ["*/chat"] })).toEqual({
root: "live",
publish: ["camera1/**"],
subscribe: ["*/chat"],
});
expect(encodeGrants({ root: "live", publish: ["camera1"] })).toEqual({ root: "live", publish: ["camera1"] });
});

test("claims exp and iat are whole seconds", () => {
Expand Down
65 changes: 39 additions & 26 deletions js/auth/src/claims.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import { Pattern, Patterns } from "@moq/pattern";
import * as z from "@zod/mini";
import * as Path from "./path.ts";
import { decodeGrants } from "./wire.ts";

/** A list of pattern texts, each validated by `Pattern.parse`. */
export const PatternListSchema = z.array(
Expand All @@ -30,52 +31,64 @@ export function patterns(texts: readonly string[] | undefined): Patterns {
return new Patterns((texts ?? []).map((text) => Pattern.parse(text)));
}

const ScopeFields = {
/** The root that `publish` and `subscribe` are relative to. Defaults to the empty string. */
root: z._default(z.string(), ""),
/** Patterns this key may grant to publishers, relative to `root`. */
publish: z.optional(PatternListSchema),
/** Patterns this key may grant to subscribers, relative to `root`. */
subscribe: z.optional(PatternListSchema),
};

/**
* The immutable ceiling on what a key may grant, embedded in its JWK.
*
* `root` is optional on the wire to match the Rust `moq-auth` crate, which omits it
* when the scope sits at the top level. Any other field, including the retired `put`
* and `get` prefix lists, is refused.
* when the scope sits at the top level. A legacy `put`/`get` prefix scope reads as the
* subtree patterns it meant. Any other field is refused.
*/
export const ScopeSchema = z
.strictObject({
/** The root that `publish` and `subscribe` are relative to. Defaults to the empty string. */
root: z._default(z.string(), ""),
/** Patterns this key may grant to publishers, relative to `root`. */
publish: z.optional(PatternListSchema),
/** Patterns this key may grant to subscribers, relative to `root`. */
subscribe: z.optional(PatternListSchema),
})
.pipe(
z.strictObject({
...ScopeFields,
put: z.optional(z.array(z.string())),
get: z.optional(z.array(z.string())),
}),
z.transform(decodeGrants),
)
.check(
z.refine((data) => (data.publish?.length ?? 0) > 0 || (data.subscribe?.length ?? 0) > 0, {
message: "Either publish or subscribe must contain at least one pattern",
}),
);

export type Scope = z.infer<typeof ScopeSchema>;
export type Scope = z.output<typeof ScopeSchema>;

const ClaimsFields = {
...ScopeFields,
/** Expiration time, as a whole unix timestamp in seconds. */
exp: z.optional(z.int()),
/** Issued-at time, as a whole unix timestamp in seconds. */
iat: z.optional(z.int()),
};

const PrefixListSchema = z.union([z.string(), z.array(z.string())]);

/**
* The JWT claims structure for moq-auth.
*
* `root` is optional on the wire: a token scoped to the top-level path omits it, so
* it defaults to the empty string to match the Rust `moq-auth` crate. A pattern names
* exactly what it says: `alice` is one broadcast, `alice/**` is a subtree, and `**` is
* everything under the root. Any other field, including the retired `put` and `get`
* prefix lists, fails verification.
* everything under the root. Legacy `moq-token` claims read too, each `put`/`get`
* prefix `p` as the subtree `p/**`, and signing writes that form whenever it says the
* same thing. Any other field fails verification.
*/
export const ClaimsSchema = z
.strictObject({
/** The root that `publish` and `subscribe` are relative to. Defaults to the empty string. */
root: z._default(z.string(), ""),
/** Patterns the holder may publish to, relative to `root`. */
publish: z.optional(PatternListSchema),
/** Patterns the holder may subscribe to, relative to `root`. */
subscribe: z.optional(PatternListSchema),
/** Expiration time, as a whole unix timestamp in seconds. */
exp: z.optional(z.int()),
/** Issued-at time, as a whole unix timestamp in seconds. */
iat: z.optional(z.int()),
})
.pipe(
z.strictObject({ ...ClaimsFields, put: z.optional(PrefixListSchema), get: z.optional(PrefixListSchema) }),
z.transform(decodeGrants),
)
.check(
// Emptiness, not just presence: `publish: []` grants nothing, and the Rust crate
// rejects such a token as useless. Checking `!== undefined` here would mint
Expand All @@ -88,7 +101,7 @@ export const ClaimsSchema = z
/**
* JWT claims structure for moq-auth
*/
export type Claims = z.infer<typeof ClaimsSchema>;
export type Claims = z.output<typeof ClaimsSchema>;

/**
* The access a {@link Claims} grants at a specific path, with every pattern rebased so
Expand Down
6 changes: 4 additions & 2 deletions js/auth/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Command, Option } from "commander";
import type { Algorithm } from "./algorithm.ts";
import { authorize, type Claims, type Scope, ScopeSchema } from "./claims.ts";
import { Key } from "./key.ts";
import { encodeGrants } from "./wire.ts";

const program = new Command();

Expand Down Expand Up @@ -37,8 +38,9 @@ program
key = { ...key, scope };
}

const encodeKey = (k: object): string => {
const json = JSON.stringify(k, null, 2);
const encodeKey = (k: { scope?: Scope }): string => {
// Written the legacy way when that says the same thing, so older readers load it.
const json = JSON.stringify(k.scope ? { ...k, scope: encodeGrants(k.scope) } : k, null, 2);
if (options.base64) {
return base64.fromArrayBuffer(new TextEncoder().encode(json).buffer, true);
}
Expand Down
22 changes: 20 additions & 2 deletions js/auth/src/key.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -694,7 +694,7 @@ test("verify - claims validation during verification", async () => {
expect(verifiedClaims.root).toBe("test-path");
});

test("verify - a token carrying the retired put/get prefix fields is refused", async () => {
test("verify - a legacy put/get prefix token reads as subtrees", async () => {
const key = Key.parse(encodeJwk(testKey));
const secret = await crypto.subtle.importKey(
"raw",
Expand All @@ -707,7 +707,25 @@ test("verify - a token carrying the retired put/get prefix fields is refused", a
const legacy = await new SignJWT({ root: "test-path", put: ["alice"], get: [""] })
.setProtectedHeader({ alg: "HS256", kid: testKey.kid })
.sign(secret);
await expect(Key.verify(key, legacy)).rejects.toThrow(/put|Unrecognized/);
const claims = await Key.verify(key, legacy);
expect(claims.publish).toEqual(["alice/**"]);
expect(claims.subscribe).toEqual(["**"]);
});

test("sign - subtree grants are written as legacy put/get", async () => {
const key = Key.parse(encodeJwk(testKey));
const token = await Key.sign(key, { root: "test-path", publish: ["alice/**"], subscribe: ["**"] });
const payload = JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString());
expect(payload).toEqual({ root: "test-path", put: ["alice"], get: [""] });
});

test("sign - legacy-shaped input cannot slip past a key scope", async () => {
const scoped: Key = {
...Key.parse(encodeJwk(testKey)),
scope: { root: "demo", publish: ["inside/**"] },
};
const legacy = { root: "demo", put: ["outside"] } as unknown as Parameters<typeof Key.sign>[1];
await expect(Key.sign(scoped, legacy)).rejects.toThrow(/scope/);
});

test("key scope is enforced when signing and verifying", async () => {
Expand Down
12 changes: 8 additions & 4 deletions js/auth/src/key.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as z from "@zod/mini";
import * as jose from "jose";
import { type Algorithm, AlgorithmSchema } from "./algorithm.ts";
import { type Claims, ClaimsSchema, ScopeSchema, scopeAllows } from "./claims.ts";
import { encodeGrants } from "./wire.ts";

/**
* A validated key identifier (kid). Only alphanumeric, hyphens, and underscores.
Expand Down Expand Up @@ -185,16 +186,19 @@ function parse(jwk: string): Key {
async function sign(key: Key, claims: Claims): Promise<string> {
ensureOperationSupported(key, "sign");

// Validate claims before signing
// Scope-check and sign what the schema parsed, never the raw input: an untyped
// caller could pass legacy `put`/`get` fields the scope check would not see.
let parsed: Claims;
try {
ClaimsSchema.parse(claims);
parsed = ClaimsSchema.parse(claims);
} catch (error) {
throw new Error(`Invalid claims: ${error instanceof Error ? error.message : "unknown error"}`);
}
ensureClaimsWithinScope(key, claims);
ensureClaimsWithinScope(key, parsed);

const joseKey = await importJoseKey(key);
const jwt = await new jose.SignJWT(claims)
// Written the legacy way when that says the same thing, so older verifiers accept it.
const jwt = await new jose.SignJWT(encodeGrants(parsed))
.setProtectedHeader({
alg: key.alg,
typ: "JWT",
Expand Down
83 changes: 83 additions & 0 deletions js/auth/src/wire.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/**
* The JSON encoding of the grants in claims and key scopes.
*
* Grants were once prefix lists named `put` and `get`, and every published `moq-token`
* reader still expects them. Must stay in lockstep with `wire.rs` in the Rust
* `moq-auth` crate.
*
* @module
*/

import { Pattern, Patterns } from "@moq/pattern";

/** One grant in whichever encoding it arrived: legacy `put`/`get` prefix lists, or patterns. */
type WireGrants = {
put?: string | string[];
get?: string | string[];
publish?: string[];
subscribe?: string[];
};

/**
* Read legacy `moq-token` prefix grants as the subtree patterns they always meant.
*
* A prefix `p` is exactly `p/**` (and `""` is `**`). A document never mixes the two
* encodings, and a legacy prefix containing `*` is refused: it had no wildcards, so
* reading one now would silently widen the grant.
*/
export function decodeGrants<T extends WireGrants>(
wire: T,
ctx: { issues: unknown[] },
): Omit<T, "put" | "get"> & { publish?: string[]; subscribe?: string[] } {
const { put, get, ...rest } = wire;
if (put === undefined && get === undefined) return rest;

const fail = (message: string) => {
ctx.issues.push({ code: "custom", message, input: wire });
return rest;
};
if (rest.publish !== undefined || rest.subscribe !== undefined) {
return fail("mixes the legacy put/get fields with publish/subscribe");
}

const subtrees = (prefixes: string | string[] | undefined) =>
prefixes === undefined
? undefined
: (typeof prefixes === "string" ? [prefixes] : prefixes).map((prefix) => Pattern.subtree(prefix).text);

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 maximum-depth legacy prefixes

When a legacy token uses an empty root and a valid 32-segment put or get prefix, Pattern.subtree appends **, producing 33 segments and throwing because patterns are limited to 32. The transform consequently rejects a previously valid credential in both implementations, even though at maximum path depth the prefix is equivalent to the 32-segment literal because no deeper valid path exists. Decode this boundary case as a literal, and consider the equivalent reverse encoding, to preserve published-wire compatibility. (Written by GPT-5.6 Sol)

AGENTS.md reference: AGENTS.md:L75-L77

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.

Declining: a legacy prefix at the full 32-segment depth is refused loudly rather than misread, which matches the fail-closed rule. Decoding it as a literal would add a depth special case to both languages' encoders for credentials that are unlikely to exist. Happy to revisit if one shows up.

(Written by Claude Opus 5.5)

try {
return { ...rest, publish: subtrees(put), subscribe: subtrees(get) };
} catch (error) {
return fail(`legacy prefix: ${error instanceof Error ? error.message : String(error)}`);
}
}

/**
* Write grants the legacy way when every pattern is a subtree, so every published
* `moq-token` reader agrees on what they grant. Anything a prefix can't say is written
* as `publish`/`subscribe`, which an older reader refuses rather than misreads.
*/
export function encodeGrants<T extends { publish?: string[]; subscribe?: string[] }>(
grants: T,
): Omit<T, "publish" | "subscribe"> & WireGrants {
const { publish, subscribe, ...rest } = grants;
const prefixes = (texts: string[] | undefined) => {
const out: string[] = [];
for (const pattern of new Patterns((texts ?? []).map((text) => Pattern.parse(text)))) {
const prefix = pattern.asPrefix();
if (prefix === undefined) return undefined;
out.push(prefix);
}
return out;
};

const put = prefixes(publish);
const get = prefixes(subscribe);
if (put === undefined || get === undefined) {
return {
...rest,
...(publish?.length && { publish }),
...(subscribe?.length && { subscribe }),
};
}
return { ...rest, ...(put.length && { put }), ...(get.length && { get }) };
}
Loading
Loading