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
33 changes: 33 additions & 0 deletions js/net/src/auth_gate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { expect, test } from "bun:test";
import { Signal } from "@moq/signals";
import type { Grant } from "./auth.ts";
import { Gate } from "./auth_gate.ts";
import * as Path from "./path.ts";

const grant = (prefix: string): Grant => ({
publish: new Path.Patterns([Path.Pattern.subtree(prefix)]),
subscribe: new Path.Patterns([]),
});

test("a gate allows everything until the peer answers", () => {
const gate = new Gate(new Signal<Grant | undefined>(undefined));
expect(gate.allows(Path.from("room/alice"))).toBe(true);
expect(new Gate().allows(Path.from("room/alice"))).toBe(true);
});

test("a watch revokes once when the grant stops covering its path", async () => {
const peer = new Signal<Grant | undefined>(grant("room"));
const gate = new Gate(peer);
let revoked = 0;
const watch = gate.watch(Path.from("room/alice"), () => revoked++);
peer.set(grant("room/alice"));
await Promise.resolve();
expect(revoked).toBe(0);
peer.set(grant("other"));
await Promise.resolve();
peer.set(grant("elsewhere"));
await Promise.resolve();
expect(revoked).toBe(1);
expect(watch.revoked()).toBe(true);
watch.dispose();
});
31 changes: 31 additions & 0 deletions js/net/src/auth_gate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { Dispose, Getter } from "@moq/signals";
import type { Grant } from "./auth.ts";
import type * as Path from "./path.ts";

// private: plumbing
/** Whether our grant still lets us serve one incoming request, built when it is dispatched. @internal */
export class Gate {
#grant?: Getter<Grant | undefined>;

constructor(grant?: Getter<Grant | undefined>) {
this.#grant = grant;
}

/** Whether our grant lets us publish `path` right now. No grant yet allows it. */
allows(path: Path.Valid): boolean {
const grant = this.#grant?.peek();
return grant === undefined || grant.publish.matches(path);
}

/** Call `onRevoke` once our grant stops covering `path`. Arm it before the request's first check. */
watch(path: Path.Valid, onRevoke: () => void): { revoked: () => boolean; dispose: Dispose } {
let revoked = false;
const dispose =
this.#grant?.subscribe(() => {
if (revoked || this.allows(path)) return;
revoked = true;
onRevoke();
}) ?? (() => {});
return { revoked: () => revoked || !this.allows(path), dispose };
}
}
35 changes: 15 additions & 20 deletions js/net/src/ietf/publisher.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Instant, Milli, Nano, now, sleep } from "@moq/clock";
import { type Dispose, type Getter, race, Signal } from "@moq/signals";
import type { Auth, Grant } from "../auth.ts";
import { Gate } from "../auth_gate.ts";
import { enforceGrant } from "../auth_session.ts";
import type * as broadcast from "../broadcast.ts";
import { Withdrawal } from "../connection/withdrawal.ts";
Expand Down Expand Up @@ -304,6 +305,8 @@ export class Publisher {
// shrink withdraws what it no longer covers. Undefined until the peer answers, which
// allows everything.
#grant: Getter<Grant | undefined>;
// Holds every incoming request to that grant from dispatch to its end.
#gate: Gate;

// Resolves once the tokens this session presented at setup are answered, so nothing is
// advertised before the grant it would be checked against.
Expand Down Expand Up @@ -354,6 +357,7 @@ export class Publisher {
this.#subscriptions = new Slots(limits.subscriptions, { quic, reason: "too many subscriptions" });
this.#fetches = new Slots(limits.fetches, { quic, reason: "too many fetches" });
this.#grant = grant;
this.#gate = new Gate(grant);
this.#auth = auth;
this.#ready = ready;
this.#session = session;
Expand Down Expand Up @@ -390,7 +394,7 @@ export class Publisher {
}
// Only a subscription we would serve holds a slot, as in Rust: a refused one ends at
// once. A peer past the cap loses the session.
const charged = msg.forward && !msg.rangeFilters && !this.#denied(msg.trackNamespace);
const charged = msg.forward && !msg.rangeFilters && this.#gate.allows(msg.trackNamespace);
if (charged) this.#subscriptions.acquire();
try {
await this.#serveSubscribe(msg, stream);
Expand Down Expand Up @@ -430,7 +434,7 @@ export class Publisher {
),
reasonPhrase: unsupported,
};
} else if (this.#denied(name)) {
} else if (!this.#gate.allows(name)) {
// Serve only what our grant lets us publish. Checked before resolving, so a denied
// request never reaches the origin.
refusal = { errorCode: toRequestCode("unauthorized", "subscribe", version), reasonPhrase: "not granted" };
Expand Down Expand Up @@ -728,11 +732,9 @@ export class Publisher {
const revoked = new Promise<"revoked">((resolve) => {
revoke = () => resolve("revoked");
});
const disposeGrant = this.#grant.subscribe(() => {
if (this.#denied(name)) revoke();
});
const watch = this.#gate.watch(name, revoke);
// The grant may have shrunk during setup, before this watcher existed.
if (this.#denied(name)) revoke();
if (watch.revoked()) revoke();

let publishError: Error | undefined;
let unauthorized = false;
Expand All @@ -749,7 +751,7 @@ export class Publisher {
} catch (err: unknown) {
publishError = error(err);
} finally {
disposeGrant();
watch.dispose();
}
// An update that broke the protocol, such as one whose AUTHORIZATION TOKEN names an
// alias, closes the session: the connection answers a thrown violation with its code.
Expand Down Expand Up @@ -1139,12 +1141,6 @@ export class Publisher {
}
}

// Whether our grant excludes publishing this broadcast. No grant yet allows it.
#denied(broadcast: Path.Valid): boolean {
const grant = this.#grant.peek();
return grant !== undefined && !grant.publish.matches(broadcast);
}

/**
* Close the session when our origin publishes a broadcast our grant does not cover;
* see {@link enforceGrant}.
Expand Down Expand Up @@ -1219,7 +1215,7 @@ export class Publisher {
const carries = (covered: Path.Valid) =>
visible(covered) &&
(legacy ? this.#requiresSolicitation || (this.#hidden && hiddenBelow(Path.empty(), covered)) : true) &&
!this.#denied(covered);
this.#gate.allows(covered);

// Nothing is advertised before the grant it would be checked against.
await this.#ready;
Expand Down Expand Up @@ -1438,7 +1434,7 @@ export class Publisher {
// nothing our grant does not cover reaches the peer at all (MoQ Auth).
if (
(this.#hidden && hiddenBelow(Path.empty(), covered)) ||
this.#denied(covered) ||
!this.#gate.allows(covered) ||
candidates.length === 0
)
continue;
Expand Down Expand Up @@ -1793,7 +1789,7 @@ export class Publisher {

// Answer only for what our grant lets us publish. Checked before the track resolves,
// so a denied request never reaches the origin.
if (this.#denied(name)) {
if (!this.#gate.allows(name)) {
await this.#refuse(stream, msg.requestId, TRACK_STATUS_ERROR_14, denied);
return;
}
Expand All @@ -1807,8 +1803,7 @@ export class Publisher {
// A grant that narrows abandons the lookup, and resets an answer the transport has
// not taken yet rather than delivering it.
let answering = false;
const disposeGrant = this.#grant.subscribe(() => {
if (!this.#denied(name)) return;
const watch = this.#gate.watch(name, () => {
hold.abort();
if (answering) stream.abort(unauthorized(name));
});
Expand Down Expand Up @@ -1839,7 +1834,7 @@ export class Publisher {
stream.abort(cancel);
return;
}
const refusal = this.#denied(name) ? denied : this.#failed(err, "track_status");
const refusal = !this.#gate.allows(name) ? denied : this.#failed(err, "track_status");
await this.#refuse(stream, msg.requestId, TRACK_STATUS_ERROR_14, refusal);
return;
}
Expand All @@ -1851,7 +1846,7 @@ export class Publisher {
stream.close();
await stream.writer.closed.catch(() => undefined);
} finally {
disposeGrant();
watch.dispose();
hold.abort();
}
}
Expand Down
29 changes: 7 additions & 22 deletions js/net/src/lite/publisher.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Instant, Milli, Nano, now, sleep } from "@moq/clock";
import { type Dispose, type Getter, Once, race, Signal } from "@moq/signals";
import type { Grant } from "../auth.ts";
import { Gate } from "../auth_gate.ts";
import { enforceGrant } from "../auth_session.ts";
import type * as broadcast from "../broadcast.ts";
import { Withdrawal } from "../connection/withdrawal.ts";
Expand Down Expand Up @@ -398,6 +399,8 @@ export class Publisher {
// withdraws what it no longer covers. Undefined until the peer answers, and forever on a
// version without AUTH, which allows everything.
#grant?: Getter<Grant | undefined>;
// Holds every incoming request to that grant from dispatch to its end.
#gate: Gate;

// Resolves once the tokens this session presented at setup are answered, so nothing is
// announced before the grant it would be checked against.
Expand Down Expand Up @@ -435,6 +438,7 @@ export class Publisher {
this.#interests = new Interests(new Slots(limits.subscriptions, { quic, reason: "too many subscriptions" }));
this.#fetches = new Slots(limits.fetches, { quic, reason: "too many fetches" });
this.#grant = auth?.grant;
this.#gate = new Gate(auth?.grant);
this.#ready = auth?.ready ?? Promise.resolve();
this.version = version;
this.hop = hop;
Expand Down Expand Up @@ -681,7 +685,7 @@ export class Publisher {
// broadcast resolves is never missed.
const revoked = unauthorized(msg.broadcast);
let serving: track.Subscriber | undefined;
const watch = this.#watch(msg.broadcast, () => {
const watch = this.#gate.watch(msg.broadcast, () => {
console.debug(`publish revoked: broadcast=${msg.broadcast} track=${msg.track}`);
serving?.close(revoked);
stream.abort(revoked);
Expand All @@ -696,19 +700,6 @@ export class Publisher {
}
}

// Watch whether our grant still lets us publish `broadcast`, calling `onRevoke` once
// it does not. Arm it before the request's first check.
#watch(broadcast: Path.Valid, onRevoke: () => void): { revoked: () => boolean; dispose: () => void } {
let revoked = false;
const dispose =
this.#grant?.subscribe(() => {
if (revoked || !this.#denied(broadcast)) return;
revoked = true;
onRevoke();
}) ?? (() => {});
return { revoked: () => revoked || this.#denied(broadcast), dispose };
}

async #serveSubscribe(
msg: Subscribe,
stream: Stream,
Expand Down Expand Up @@ -869,7 +860,7 @@ export class Publisher {
// open as long as its track, so a check at accept alone would keep serving after a shrink.
const revoked = unauthorized(msg.broadcast);
let fetched: group.Consumer | undefined;
const watch = this.#watch(msg.broadcast, () => {
const watch = this.#gate.watch(msg.broadcast, () => {
console.debug(`fetch revoked: broadcast=${msg.broadcast} track=${msg.track} group=${msg.group}`);
fetched?.close(revoked);
stream.abort(revoked);
Expand Down Expand Up @@ -1280,7 +1271,7 @@ export class Publisher {
hold.signal.addEventListener("abort", () => interest.release(), { once: true });
// Armed before the first check and held until the answer is acknowledged, so a grant
// that shrinks while the answer is blocked on flow control resets it, never sends it.
const watch = this.#watch(msg.broadcast, () => {
const watch = this.#gate.watch(msg.broadcast, () => {
console.debug(`track info revoked: broadcast=${msg.broadcast} track=${msg.track}`);
hold.abort();
stream.writer.reset(unauthorized(msg.broadcast));
Expand Down Expand Up @@ -1323,12 +1314,6 @@ export class Publisher {
}
}

// Whether our grant excludes publishing this broadcast. No grant yet allows it.
#denied(broadcast: Path.Valid): boolean {
const grant = this.#grant?.peek();
return grant !== undefined && !grant.publish.matches(broadcast);
}

// Resolve (and cache) a track's immutable TRACK_INFO by asking the application.
// `resolveTrackInfo` triggers a TrackRequest the app answers with accept(TrackInfo);
// only the immutable properties are needed (not the groups). Cached because they're
Expand Down
2 changes: 0 additions & 2 deletions quest/m1/auth/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,8 +133,6 @@ because `main` assigned 0x3A to NOT_FETCHABLE first.
- [Live requests follow their auth](/quest/m1/auth/request-update.md) - a route swaps its auth and the session sends the update, keeping the request up
- [Auth outlives its session](/quest/m1/auth/issuer.md) - one `auth::Handle` spans a connection's sessions, so a route's auth survives a reconnect
- [Requests name their auth](/quest/m1/auth/request-auth-id.md) - lite-07 AUTH carries an ID and each request names the auth that admits it
- [Request gate](/quest/m1/auth/request-gate.md) - every incoming request is
refused up front and held to the grant through a gate its dispatcher owns
- [Bindings](/quest/m1/auth/bindings.md) - grants and tokens reach every
binding through moq-ffi
- [Token in band](/quest/m1/auth/token-in-band.md) - the credential can leave
Expand Down
54 changes: 0 additions & 54 deletions quest/m1/auth/request-gate.md

This file was deleted.

6 changes: 1 addition & 5 deletions quest/m3/auth-token-fetch.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ peer can subscribe to a track but cannot look it up or fetch its history.
Open question for the maintainer. This reverses a settled decision:
"standalone FETCH gains no request-token authority" (2026-10-12, in
[#5148](https://github.com/moq-dev/moq/pull/5148)'s quest and
[Request gate](/quest/m1/auth/request-gate.md)).
[the request gate refactor](https://github.com/moq-dev/moq/pull/5782)).

1. (recommended) Do it in m1, after the request gate. Move this file under
[In-band auth](/quest/m1/auth/README.md) and list it in that epic's
Expand Down Expand Up @@ -49,7 +49,3 @@ token. Run cross-language interop for the wire change.
Public API: none expected beyond widening what `auth::RequestKind` names.
Wire: the existing AUTHORIZATION TOKEN parameter (0x03), now sent and honored
on TRACK_STATUS and FETCH.

## Required

- [Request gate](/quest/m1/auth/request-gate.md) - the dispatcher-owned gate this extends to two more request types
Loading
Loading