diff --git a/doc/bin/relay/auth.md b/doc/bin/relay/auth.md index 0134631682..2cb99cc912 100644 --- a/doc/bin/relay/auth.md +++ b/doc/bin/relay/auth.md @@ -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 diff --git a/doc/lib/rs/moq-auth.md b/doc/lib/rs/moq-auth.md index 3de235ec27..8f3ea78286 100644 --- a/doc/lib/rs/moq-auth.md +++ b/doc/lib/rs/moq-auth.md @@ -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 diff --git a/doc/setup/upgrade.md b/doc/setup/upgrade.md index a12225da3e..99013372b3 100644 --- a/doc/setup/upgrade.md +++ b/doc/setup/upgrade.md @@ -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 @@ -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. @@ -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`. diff --git a/js/auth/src/claims.test.ts b/js/auth/src/claims.test.ts index f5114f454b..f55daead33 100644 --- a/js/auth/src/claims.test.ts +++ b/js/auth/src/claims.test.ts @@ -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. @@ -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", () => { diff --git a/js/auth/src/claims.ts b/js/auth/src/claims.ts index 1deb99c9e9..493690b947 100644 --- a/js/auth/src/claims.ts +++ b/js/auth/src/claims.ts @@ -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( @@ -30,29 +31,48 @@ 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; +export type Scope = z.output; + +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. @@ -60,22 +80,15 @@ export type Scope = z.infer; * `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 @@ -88,7 +101,7 @@ export const ClaimsSchema = z /** * JWT claims structure for moq-auth */ -export type Claims = z.infer; +export type Claims = z.output; /** * The access a {@link Claims} grants at a specific path, with every pattern rebased so diff --git a/js/auth/src/cli.ts b/js/auth/src/cli.ts index 07234f6694..43c0e0b52a 100755 --- a/js/auth/src/cli.ts +++ b/js/auth/src/cli.ts @@ -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(); @@ -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); } diff --git a/js/auth/src/key.test.ts b/js/auth/src/key.test.ts index 44b98272bb..e17740ddfe 100644 --- a/js/auth/src/key.test.ts +++ b/js/auth/src/key.test.ts @@ -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", @@ -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[1]; + await expect(Key.sign(scoped, legacy)).rejects.toThrow(/scope/); }); test("key scope is enforced when signing and verifying", async () => { diff --git a/js/auth/src/key.ts b/js/auth/src/key.ts index 6a28ade220..ac6dcb2001 100644 --- a/js/auth/src/key.ts +++ b/js/auth/src/key.ts @@ -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. @@ -185,16 +186,19 @@ function parse(jwk: string): Key { async function sign(key: Key, claims: Claims): Promise { 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", diff --git a/js/auth/src/wire.ts b/js/auth/src/wire.ts new file mode 100644 index 0000000000..798f840565 --- /dev/null +++ b/js/auth/src/wire.ts @@ -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( + wire: T, + ctx: { issues: unknown[] }, +): Omit & { 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); + 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( + grants: T, +): Omit & 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 }) }; +} diff --git a/quest/m1/path-patterns.md b/quest/m1/path-patterns.md index 4900d1beef..fff74556a5 100644 --- a/quest/m1/path-patterns.md +++ b/quest/m1/path-patterns.md @@ -59,12 +59,12 @@ trips, and the moq-net fuzz harness's `pattern` target prevent semantic drift at the authorization boundary. Matching is linear and inherits `Path::MAX_PARTS` (32), which also bounds residual expansion. -Grants and claims carry no version. `moq-auth` reads patterns only: `foo` means exactly `foo`, a -subtree is `foo/**`, and an unversioned prefix credential fails verification. -Translating the prefix credentials a deployment already issued is that -deployment's job at its own edge for a deprecation window, which is what -moq.pro (downstream) does. A wire message that carried prefixes keeps them on -the protocol versions that defined them; only new versions carry patterns. +Grants and claims carry no version. `moq-auth` grants are patterns: `foo` +means exactly `foo` and a subtree is `foo/**`. The published `put`/`get` +prefix encoding stays readable as subtrees, and subtree-only grants are still +written in it so older verifiers keep working. A wire message that carried +prefixes keeps them on the protocol versions that defined them; only new +versions carry patterns. The syntax follows Ant-style path patterns without `?`, classes, or braces. NATS subjects motivate segment wildcards and reserved wildcard bytes; Vault diff --git a/rs/moq-auth/src/claims.rs b/rs/moq-auth/src/claims.rs index c6cacef28d..d49a252f1a 100644 --- a/rs/moq-auth/src/claims.rs +++ b/rs/moq-auth/src/claims.rs @@ -1,7 +1,6 @@ use crate::path; use moq_pattern::Patterns; use serde::{Deserialize, Serialize}; -use serde_with::{TimestampSeconds, serde_as}; /// The immutable ceiling on what a key may grant, embedded in its JWK. /// @@ -13,19 +12,19 @@ use serde_with::{TimestampSeconds, serde_as}; /// is the point: a leaked scoped key can never be talked into signing more than it /// already could. A key with no scope at all is unrestricted, so keys minted before /// scopes existed keep working. +/// +/// Legacy `put`/`get` prefix scopes load as subtree patterns, and a scope that only +/// grants subtrees is written that way so older readers load it too. #[derive(Debug, Serialize, Deserialize, Default, Clone, PartialEq, Eq)] -#[serde(default, deny_unknown_fields)] +#[serde(try_from = "crate::wire::Scope", into = "crate::wire::Scope")] pub struct Scope { /// The root for the publish/subscribe patterns below. - #[serde(skip_serializing_if = "String::is_empty")] pub root: String, /// Patterns this key may grant to publishers. - #[serde(skip_serializing_if = "Patterns::is_empty")] pub publish: Patterns, /// Patterns this key may grant to subscribers. - #[serde(skip_serializing_if = "Patterns::is_empty")] pub subscribe: Patterns, } @@ -103,37 +102,30 @@ impl Permissions { /// .with_subscribe(["**".parse().unwrap()]); /// ``` /// -/// Any other field, including the retired `put` and `get` prefix lists, fails -/// verification: a token either speaks patterns or it is not one of ours. -#[serde_with::skip_serializing_none] -#[serde_as] +/// Legacy `moq-token` claims are read too: each `put`/`get` prefix `p` is the subtree +/// `p/**`. Claims that only grant subtrees are written that way, so every published +/// verifier accepts them; anything else is written as `publish`/`subscribe`, which an +/// older verifier refuses rather than misreads. Any other field fails verification. #[derive(Debug, Serialize, Deserialize, Default, Clone)] -#[serde(default, deny_unknown_fields)] +#[serde(try_from = "crate::wire::Claims", into = "crate::wire::Claims")] #[non_exhaustive] pub struct Claims { /// The root for the publish/subscribe patterns below. /// It's mostly for compression and is optional, defaulting to the empty string. - #[serde(skip_serializing_if = "String::is_empty")] pub root: String, /// If specified, the user can publish any matching broadcasts. /// If not specified, the user will not publish any broadcasts. - #[serde(skip_serializing_if = "Patterns::is_empty")] pub publish: Patterns, /// If specified, the user can subscribe to any matching broadcasts. /// If not specified, the user will not receive announcements and cannot subscribe to any broadcasts. - #[serde(skip_serializing_if = "Patterns::is_empty")] pub subscribe: Patterns, - /// The expiration time of the token as a unix timestamp. - #[serde(rename = "exp")] - #[serde_as(as = "Option>")] + /// The expiration time of the token as a unix timestamp (`exp`). pub expires: Option, - /// The issued time of the token as a unix timestamp. - #[serde(rename = "iat")] - #[serde_as(as = "Option>")] + /// The issued time of the token as a unix timestamp (`iat`). pub issued: Option, } @@ -400,9 +392,38 @@ mod tests { } #[test] - fn scope_refuses_the_old_prefix_fields() { - let err = serde_json::from_str::(r#"{"root":"demo","put":["room"]}"#).unwrap_err(); - assert!(err.to_string().contains("unknown field `put`"), "{err}"); + fn scope_refuses_null_grants() { + assert!(serde_json::from_str::(r#"{"put":null,"publish":["room"]}"#).is_err()); + } + + #[test] + fn scope_reads_legacy_prefixes_as_subtrees() { + let scope: Scope = serde_json::from_str(r#"{"root":"demo","put":["room"],"get":[""]}"#).unwrap(); + assert_eq!(scope.publish, patterns(&["room/**"])); + assert_eq!(scope.subscribe, patterns(&["**"])); + } + + #[test] + fn scope_writes_legacy_prefixes_only_when_faithful() { + let subtrees = Scope { + root: "demo".into(), + publish: patterns(&["room/**"]), + subscribe: patterns(&["**"]), + }; + assert_eq!( + serde_json::to_string(&subtrees).unwrap(), + r#"{"root":"demo","put":["room"],"get":[""]}"# + ); + + let exact = Scope { + root: "demo".into(), + publish: patterns(&["room"]), + subscribe: Patterns::new(), + }; + assert_eq!( + serde_json::to_string(&exact).unwrap(), + r#"{"root":"demo","publish":["room"]}"# + ); } #[test] @@ -476,17 +497,61 @@ mod tests { } #[test] - fn test_claims_refuse_the_old_prefix_fields() { + fn test_claims_read_legacy_prefixes_as_subtrees() { + let claims: Claims = + serde_json::from_str(r#"{"root":"test","put":["pub1","/a//b/"],"get":"","exp":1700000000}"#).unwrap(); + assert_eq!(claims.publish, patterns(&["pub1/**", "a/b/**"])); + assert_eq!(claims.subscribe, patterns(&["**"])); + assert!(claims.expires.is_some()); + } + + #[test] + fn test_claims_write_legacy_prefixes_only_when_faithful() { + // Every grant is a subtree, so the legacy form says exactly the same thing. + let subtrees = Claims { + root: "live".into(), + publish: patterns(&["camera1/**"]), + subscribe: patterns(&["**"]), + ..Default::default() + }; + let json = serde_json::to_string(&subtrees).unwrap(); + assert_eq!(json, r#"{"root":"live","put":["camera1"],"get":[""]}"#); + let back: Claims = serde_json::from_str(&json).unwrap(); + assert_eq!(back.publish, subtrees.publish); + assert_eq!(back.subscribe, subtrees.subscribe); + + // One grant a prefix can't say moves the whole document to patterns. + let mixed = Claims { + root: "live".into(), + publish: patterns(&["camera1/**"]), + subscribe: patterns(&["*/chat"]), + ..Default::default() + }; + assert_eq!( + serde_json::to_string(&mixed).unwrap(), + r#"{"root":"live","publish":["camera1/**"],"subscribe":["*/chat"]}"# + ); + } + + #[test] + fn test_claims_refuse_mixed_or_unknown_fields() { for json in [ - r#"{"root":"test","put":["pub1"]}"#, - r#"{"root":"test","get":"sub1"}"#, r#"{"root":"test","publish":["pub1"],"get":["sub1"]}"#, + r#"{"root":"test","put":[],"subscribe":["sub1"]}"#, + r#"{"root":"test","put":["pub1"],"cluster":true}"#, + r#"{"root":"test","put":null,"publish":["pub1"]}"#, + r#"{"root":"test","publish":null,"subscribe":["sub1"]}"#, ] { - let err = serde_json::from_str::(json).unwrap_err(); - assert!(err.to_string().contains("unknown field"), "{json}: {err}"); + assert!(serde_json::from_str::(json).is_err(), "{json}"); } } + #[test] + fn test_claims_refuse_a_wildcard_in_a_legacy_prefix() { + // Legacy prefixes had no wildcards; a `*` would silently widen the grant. + assert!(serde_json::from_str::(r#"{"put":["a/*"]}"#).is_err()); + } + #[test] fn test_claims_refuse_a_bad_pattern() { let err = serde_json::from_str::(r#"{"publish":["a/**/b/**"]}"#).unwrap_err(); diff --git a/rs/moq-auth/src/key.rs b/rs/moq-auth/src/key.rs index c1488dcbf6..fc584803de 100644 --- a/rs/moq-auth/src/key.rs +++ b/rs/moq-auth/src/key.rs @@ -1603,11 +1603,40 @@ mod tests { } #[test] - fn test_js_legacy_prefix_token_is_refused() { - // The signature is fine; the claims speak prefixes, which is no longer a token. + fn test_js_legacy_prefix_token_verifies_as_subtrees() { let key = Key::from_str(JS_HS256_KEY).unwrap(); - let err = key.verify(JS_HS256_LEGACY_TOKEN).unwrap_err(); - assert!(err.to_string().contains("unknown field"), "{err}"); + let claims = key.verify(JS_HS256_LEGACY_TOKEN).unwrap(); + assert_eq!(claims.root, "live"); + assert_eq!(claims.publish, patterns(&["camera1/**"])); + assert_eq!(claims.subscribe, patterns(&["camera1/**", "camera2/**"])); + } + + #[test] + fn test_legacy_scoped_key_signs_within_its_prefixes() { + // A key minted by moq-token-cli with `--root demo --put room`. + let json = r#"{"kty":"oct","alg":"HS256","key_ops":["sign","verify"],"k":"Fp8kipWUJeUFqeSqWym_tRC_tyI8z-QpqopIGrbrD68","scope":{"root":"demo","put":["room"]}}"#; + let key = Key::from_str(json).unwrap(); + assert_eq!(key.scope.as_ref().unwrap().publish, patterns(&["room/**"])); + + let inside = Claims { + root: "demo/room".into(), + publish: patterns(&["alice"]), + ..Default::default() + }; + let outside = Claims { + root: "demo".into(), + publish: patterns(&["lobby/**"]), + ..Default::default() + }; + assert!(key.verify(&key.sign(&inside).unwrap()).is_ok()); + assert!(matches!(key.sign(&outside), Err(crate::Error::ScopeExceeded))); + + // Written back the way it was read, so the old CLI still loads it. + assert!( + serde_json::to_string(&key) + .unwrap() + .contains(r#""scope":{"root":"demo","put":["room"]}"#) + ); } #[test] diff --git a/rs/moq-auth/src/lib.rs b/rs/moq-auth/src/lib.rs index 486aae7fdb..03c34f76c2 100644 --- a/rs/moq-auth/src/lib.rs +++ b/rs/moq-auth/src/lib.rs @@ -27,6 +27,7 @@ mod key_id; mod path; mod request; mod set; +mod wire; pub mod lease; #[cfg(feature = "serve")] diff --git a/rs/moq-auth/src/wire.rs b/rs/moq-auth/src/wire.rs new file mode 100644 index 0000000000..c7fedc14ef --- /dev/null +++ b/rs/moq-auth/src/wire.rs @@ -0,0 +1,196 @@ +//! The JSON encoding of the grants in [`Claims`](crate::Claims) and [`Scope`](crate::Scope). +//! +//! Grants were once prefix lists named `put` and `get`, and every published `moq-token` +//! reader still expects them. A prefix `p` means exactly the pattern `p/**` (and `""` +//! means `**`), so grants that are all subtrees are written the old way and every +//! reader, old or new, agrees on what they grant. Anything a prefix can't say is written +//! as `publish` and `subscribe`, which an old reader sees as granting nothing and +//! refuses. Both encodings are read; one document never mixes them. + +use moq_pattern::{Pattern, Patterns}; +use serde::{Deserialize, Deserializer, Serialize}; +use serde_with::{TimestampSeconds, serde_as}; + +/// The wire form of [`Claims`](crate::Claims). +#[serde_with::skip_serializing_none] +#[serde_as] +#[derive(Serialize, Deserialize, Default)] +#[serde(default, deny_unknown_fields)] +pub(crate) struct Claims { + #[serde(skip_serializing_if = "String::is_empty")] + root: String, + #[serde(deserialize_with = "present")] + put: Option, + #[serde(deserialize_with = "present")] + get: Option, + #[serde(deserialize_with = "present")] + publish: Option, + #[serde(deserialize_with = "present")] + subscribe: Option, + #[serde_as(as = "Option>")] + exp: Option, + #[serde_as(as = "Option>")] + iat: Option, +} + +impl From for Claims { + fn from(claims: crate::Claims) -> Self { + let grants = Grants::encode(claims.publish, claims.subscribe); + Self { + root: claims.root, + put: grants.put.map(Prefixes::Many), + get: grants.get.map(Prefixes::Many), + publish: grants.publish, + subscribe: grants.subscribe, + exp: claims.expires, + iat: claims.issued, + } + } +} + +impl TryFrom for crate::Claims { + type Error = String; + + fn try_from(wire: Claims) -> Result { + let grants = Grants { + put: wire.put.map(Prefixes::into_vec), + get: wire.get.map(Prefixes::into_vec), + publish: wire.publish, + subscribe: wire.subscribe, + }; + let (publish, subscribe) = grants.decode()?; + Ok(Self { + root: wire.root, + publish, + subscribe, + expires: wire.exp, + issued: wire.iat, + }) + } +} + +/// The wire form of [`Scope`](crate::Scope). Legacy scopes only ever held lists. +#[serde_with::skip_serializing_none] +#[derive(Serialize, Deserialize, Default)] +#[serde(default, deny_unknown_fields)] +pub(crate) struct Scope { + #[serde(skip_serializing_if = "String::is_empty")] + root: String, + #[serde(deserialize_with = "present")] + put: Option>, + #[serde(deserialize_with = "present")] + get: Option>, + #[serde(deserialize_with = "present")] + publish: Option, + #[serde(deserialize_with = "present")] + subscribe: Option, +} + +impl From for Scope { + fn from(scope: crate::Scope) -> Self { + let grants = Grants::encode(scope.publish, scope.subscribe); + Self { + root: scope.root, + put: grants.put, + get: grants.get, + publish: grants.publish, + subscribe: grants.subscribe, + } + } +} + +impl TryFrom for crate::Scope { + type Error = String; + + fn try_from(wire: Scope) -> Result { + let grants = Grants { + put: wire.put, + get: wire.get, + publish: wire.publish, + subscribe: wire.subscribe, + }; + let (publish, subscribe) = grants.decode()?; + Ok(Self { + root: wire.root, + publish, + subscribe, + }) + } +} + +/// Legacy claims wrote a single prefix as a bare string. +#[derive(Serialize, Deserialize)] +#[serde(untagged)] +enum Prefixes { + One(String), + Many(Vec), +} + +impl Prefixes { + fn into_vec(self) -> Vec { + match self { + Self::One(prefix) => vec![prefix], + Self::Many(prefixes) => prefixes, + } + } +} + +/// A present field, refusing an explicit `null` rather than reading it as absent, +/// so a `null` can't hide one encoding from the mixed-encoding check. +fn present<'de, D: Deserializer<'de>, T: Deserialize<'de>>(deserializer: D) -> Result, D::Error> { + T::deserialize(deserializer).map(Some) +} + +/// The grant fields shared by both documents, in whichever encoding they arrived. +struct Grants { + put: Option>, + get: Option>, + publish: Option, + subscribe: Option, +} + +impl Grants { + fn encode(publish: Patterns, subscribe: Patterns) -> Self { + let prefixes = |patterns: &Patterns| -> Option> { + patterns + .iter() + .map(|pattern| pattern.as_prefix().map(str::to_string)) + .collect() + }; + let some = |patterns: Patterns| (!patterns.is_empty()).then_some(patterns); + + match (prefixes(&publish), prefixes(&subscribe)) { + (Some(put), Some(get)) => Self { + put: (!put.is_empty()).then_some(put), + get: (!get.is_empty()).then_some(get), + publish: None, + subscribe: None, + }, + _ => Self { + put: None, + get: None, + publish: some(publish), + subscribe: some(subscribe), + }, + } + } + + fn decode(self) -> Result<(Patterns, Patterns), String> { + let legacy = self.put.is_some() || self.get.is_some(); + if !legacy { + return Ok((self.publish.unwrap_or_default(), self.subscribe.unwrap_or_default())); + } + if self.publish.is_some() || self.subscribe.is_some() { + return Err("mixes the legacy put/get fields with publish/subscribe".to_string()); + } + + let subtrees = |prefixes: Option>| -> Result { + prefixes + .unwrap_or_default() + .iter() + .map(|prefix| Pattern::subtree(prefix).map_err(|err| format!("legacy prefix {prefix:?}: {err}"))) + .collect() + }; + Ok((subtrees(self.put)?, subtrees(self.get)?)) + } +}