Skip to content

feat(auth): configured tokens ride SETUP and AUTH - #6177

Draft
mriise wants to merge 48 commits into
moq-dev:mainfrom
mriise:quest/m1/auth/token-in-band
Draft

mriise wants to merge 48 commits into
moq-dev:mainfrom
mriise:quest/m1/auth/token-in-band

Conversation

@mriise

@mriise mriise commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Clients take tokens as configuration separate from the URL. The first is the connection credential and rides SETUP; each other token rides its own AUTH stream and widens the grant. Finishes the quest/m1/auth/token-in-band.md quest.

Stacked on #6109. #6137 has landed. The branch carries #6137's commits, then this PR's own (from feat(net): present the connection credential in SETUP), then two merges of #6109: 5b48b53a8 ports them onto its session-spanning auth handle, and 9d4a891d2 takes its head c0b800c7a, which already contains main with #6137. Review this PR's commits and those merges; main squash-merges, so the merge commits leave no trace.

9d4a891d2 takes #6109's rs/moq-ffi/src/auth.rs (both sides had collapsed the enum), keeps the token holder beside #6109's new redirect tracking in the reconnect loop, and drops token-in-band.md from bindings-polish.md's prerequisites.

The merge with #6109:

  • collapses feat(bindings): read the session grant and present tokens in band #6137's temporary dispatch enum in rs/moq-ffi/src/auth.rs onto moq_net::auth::{Handle, Watch, Auth}, the follow-up feat(bindings): read the session grant and present tokens in band #6137 listed;

  • holds configured tokens once per connection on the shared handle (rs/moq-tokio/src/connection/tokens.rs), told each dial's connection credential, instead of re-adding them per session, which would have presented each twice while a GOAWAY'd session drained;

  • does the same in js/net: one holder per auth handle, so a reloading connection presents each configured token once.

  • Wire (moq-net): lite-07-wip gains a client-only Token setup parameter (0x6) that fills server::Handshake::token, the way moq-transport's AUTHORIZATION TOKEN option already does. moq-transport clients now write that option too. Client::with_token sets it. setup::Token's Debug prints only its type and length.

  • moq-tokio: connect::Config::tokens (--connect-token, MOQ_CONNECT_TOKEN).

    • The first token rides SETUP, and the URL query too while an offered version has no SETUP token (lite before 07).
    • A URL that already carries ?jwt= keeps it as the connection credential, and then every configured token rides AUTH. No second jwt is appended, and SETUP never carries a different value.
    • Client::tokens() returns a connect::Tokens handle for refresh:
      • every dial reads the current tokens;
      • after an authorization refusal the client redials only if they changed, so a rejected credential is terminal;
      • a live AUTH session presents replacements at once, and withdraws the replaced tokens only after the replacements are answered, so the grant never narrows in between.
    • On a session that cannot send a configured token (moq-transport without the Auth extension), the handle keeps it for a later session with AUTH, and the dial logs that once at debug.
  • moq-ffi: MoqClientConfig.tokens and MoqClient::set_tokens (see Decisions).

  • Wrappers: Python tokens= and set_tokens, Go WithTokens and SetTokens, Kotlin, Dart and Swift the same in their casing. Dart's raw bindings are regenerated.

  • js/net: lite-07's Token parameter, the moq-transport AUTHORIZATION TOKEN option, a tokens connect option with the same credential rules, and a reloading Connection whose token changes redial only after a refusal and swap AUTH tokens on a live session.

  • Spec: drafts/draft-lcurley-moq-lite.md specs the Token parameter in lite-07-wip SETUP.

  • Docs: doc/bin/cli.md, doc/bin/relay/auth.md (the URL token is one member of the union), doc/concept/transport.md, and every doc/lib client page. The ?jwt= form stays documented as the way to reach an older relay.

  • Interop: the Rust client in test/interop/interop.sh moves ?jwt= to --connect-token, so it rides SETUP on the auth version. The binding clients keep the URL form, which covers the query path.

Public API and wire impact

  • Wire: a new client-only lite-07-wip SETUP parameter, Token (0x6). Additive; lite-07 is still -wip. moq-transport clients now send AUTHORIZATION TOKEN in CLIENT_SETUP when configured.
  • moq-net: Client::with_token. server::Handshake::token is now also set on lite-07.
  • moq-tokio: connect::Config::tokens (the struct is #[non_exhaustive]), connect::{Token, Tokens, EmptyToken}, Client::tokens().
  • moq-ffi: MoqClientConfig.tokens (breaking for a Rust struct literal; MIGRATE.md entry added) and MoqClient::set_tokens.
  • Wrappers and js/net: the additions above.

Decisions needed

User preferences recorded on 2026-10-22. These describe the intended direction; the existing setter and reconnect check still need implementation changes.

  1. Binding refresh API: decided B, a tokens handle. Use MoqClient::tokens() -> MoqTokens with set(tokens), mirrored in each binding and matching moq-tokio's Client::tokens(). Initial tokens remain on MoqClientConfig. Replace the currently implemented MoqClient::set_tokens API.
    • Alternative A was a setter directly on the client; configuration-only refresh would require rebuilding the client.
  2. Reconnect race: tentative A, pending maintainer confirmation. Prefer exposing the initial presentation result on Auth, distinguishing a token accepted by the peer over AUTH from one available only for request-carried authorization. The binding must read that stable result rather than infer support from grant() == None, which also occurs during reconnects. This records how presentation completed; it does not preserve permissions while disconnected.
    • Maintainer question: is Auth the right place for this result? Recommended: preserve Handle::add and legacy request-token behavior. The alternative is to make Handle::add require AUTH and redesign the request-token path.
  3. Rejected replacement: preference and ordering rationale recorded; final policy remains open. The user selected B, retaining the old token until expiry or an accepted replacement, but also said A's authoritative replacement behavior seems simpler and has fewer ways to go wrong. Their reasoning is that the application normally receives a valid replacement token before handing it to the client.
    • B preserves the still-valid old grant if the receiving peer rejects a replacement. A withdraws replaced tokens after all replacements are answered, including outright refusals; this is the current implementation.
    • Token issuance before client handoff and acceptance by this particular peer are distinct steps, so the intended behavior when the peer nevertheless rejects the replacement still needs confirmation. Do not treat either A or B as an unconditional final decision.
    • Transient refusals continue to count as unanswered while retried under feat(auth): an auth handle that outlives its session #6109, so the existing old token remains presented during that wait.

(Written by GPT-6)

Checks

  • On 9d4a891d2, on Windows: cargo check --all-targets for moq-net, moq-tokio, moq-ffi, moq-relay and moq-cli. cargo test for moq-net, moq-tokio and moq-ffi passes. moq-relay passes except three tests that fail only in the full run (two installing the crypto provider, one waiting on a WebSocket close log) and pass alone. js/net bun run check passes; bun test src/auth src/connection fails the same set as feat(auth): an auth handle that outlives its session #6109's head under this machine's Bun 1.1.17 (no onTestFinished), and runs 9 more passing tests than it.
  • Earlier heads:
    • On 5b48b53a8, on Windows: cargo test for moq-net (all), moq-tokio (all), moq-ffi (all), and moq-relay auth (88) pass. Before the merge, moq-tokio's keylog::client_keylog_logs_only_the_client failed once; it also fails on main on Windows.
    • New regression tests fail without their fixes: a_configured_token_without_auth_is_unsupported (before the merge; now a_configured_token_without_auth_is_kept_for_a_later_session), and js/net's sessions sharing an auth handle present each configured token once.
    • Dart: regenerated with the pinned uniffi-bindgen-dart; dart analyze and dart test pass for moq_ffi and moq.
    • Python: just py test (105) passes on 5b48b53a8.
    • js/net, on 5b48b53a8: bun run check passes; the token, reload, SETUP and auth tests (216) pass.
  • Not run: just check (no Nix on this machine), the full js/net suite, Go, Kotlin, Swift, just test interop --all. moq-ffi's API is unchanged by the merge, so the bindings need no regeneration.

Follow-ups

(Written by Claude Opus 5.5)

kixelated and others added 30 commits October 20, 2026 15:27
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ndle

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fix the reconnect test, which shadowed its token and never compiled under
`--all-targets` with tcp; its OOM was the build on a loaded machine, not the
test (it now runs in 0.15s). Hold issued grants instead of leaking them.

Extend `a_handle_backs_one_active_session` to cover a draining session
keeping an unanswered token beside its successor, and a token added after the
drain riding only the successor.

Point the relay auth bench at `moq_net::auth`, update the relay refresh tests
for a transient refusal that now retries instead of failing `add()`, add
MIGRATE entries for moq-net and moq-tokio, and apply rustfmt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Mirror the moq-net change in js/net. `Auth.Handle` is now a class: a
session-independent handle passed to `connect({ auth })` presents every
token it holds on each session built on it, so a route naming one of its
`Auth`s keeps it across a reconnect. A handle backs one active session at a
time, plus the ones draining after a GOAWAY; attaching another rejects with
`Auth.Duplicate`. Only the peer's verdict, or the handle's end, ends a token.

The reconnect loop owns one handle (`Reload.auth`), surfaced as
`Connection.auth`, and aborts it when the loop closes. A transiently refused
token on moq-lite is presented again after a 1s backoff doubling to 30s, as
in Rust. `Auth.None` is removed.

Update the docs that said to add the token again and re-announce after a
reconnect, and add a MIGRATE entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Mark the wire view's auth field internal so the declaration build no longer imports an internal type, and satisfy clippy's field_reassign_with_default in the moq-tokio reconnect test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sends

- `MoqSession::auth()` returns a new handle per call, each watching the grant on
  its own and starting from the grant it was made with, so concurrent watchers no
  longer steal each other's changes and `grants()` never repeats the first grant.
- `MoqAuth::add` refuses a token with `Unsupported` when the session cannot send
  it: moq-transport without the Auth extension accepts one only for the requests
  that name it, which no binding can.
- moq-tokio publishes a token's grant before `add` returns.
- OBS keeps the relay's refusal for the dock and presents a retyped token again.
- Docs: AUTH also rides moq-transport draft-17+ with the Auth extension.
- interop.sh guards empty arrays for bash 3.2; the Swift grant wait has a watchdog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
moq-net reports a clean close as Cancel, which the connection's token task surfaced as an error, so client sessions in the bindings saw Cancelled where server sessions saw success.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…notices

- moq-tokio's connection refuses a token as unsupported when its session accepts it
  without AUTH, so a reconnect between the answer and moq-ffi's check can no longer
  turn an accepted token into Unsupported.
- The OBS dock keeps why a token edit was not presented until the next one, instead
  of losing it on the next status refresh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	dart/moq_ffi/lib/src/moq.dart
#	doc/lib/dart/index.md
#	doc/lib/kt/index.md
#	doc/lib/py/index.md
#	doc/lib/swift/index.md
mriise and others added 18 commits October 21, 2026 00:19
lite-07-wip gains a client-only Token parameter (0x6) that fills
server::Handshake::token, and moq-transport clients now write the
AUTHORIZATION TOKEN setup option. Client::with_token sets it.
setup::Token's Debug no longer prints the credential.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
connect::Config gains tokens (--connect-token, MOQ_CONNECT_TOKEN). The
first rides SETUP, and the URL query while an offered version has no
SETUP token; the rest each ride an AUTH stream. A URL that already
carries ?jwt= keeps it, and every token rides AUTH. Client::tokens()
returns a connect::Tokens handle: each dial reads it, a refusal redials
only when it changed, and a live session swaps its AUTH tokens,
withdrawing the replaced ones once their replacements are answered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MoqClientConfig gains tokens and MoqClient gains set_tokens, mirroring
moq_tokio's connect::Config::tokens and Client::tokens(). The lite draft
specs the client-only Token setup parameter (0x6). A relay test checks a
configured token reaches the auth server as the SETUP token on lite-07
and as ?jwt= on lite-06, and the interop harness's Rust client now
presents its token with --connect-token. Finishes the token-in-band quest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Python, Go, Kotlin, Dart, and Swift take tokens on the client config
and expose set_tokens, and each binding page documents them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
js/net mirrors the Rust client: lite-07's Token setup parameter, the
moq-transport AUTHORIZATION TOKEN option on the client's SETUP, a tokens
connect option with the same credential rules, and a reloading
Connection whose tokens signal redials after a refusal only once they
changed and swaps AUTH tokens on a live session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rted

On moq-transport without the Auth extension the session accepts a token at
once for the requests that name it, but a configured token rides no request.
Treat it as unsupported instead of granted, and log that once per session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
moq-dev#6109 replaces moq-tokio's connection auth with one moq-net auth handle that
spans a connection's sessions, so:

- moq-ffi's temporary dispatch enum collapses onto moq_net::auth::{Handle,
  Watch, Auth}.
- Configured tokens are held once per connection on that handle
  (connection/tokens.rs) instead of re-added per session, which would have
  presented each one twice while a GOAWAY'd session drained. Each dial tells
  the holder its connection credential.
- A configured token a moq-transport session cannot send is kept for a later
  session with AUTH rather than dropped, and logged once per dial.
- js/net holds configured tokens once per auth handle, so a reloading
  connection's shared handle presents each once across reconnects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Resolves the conflicts with moq-dev#6137: moq-tokio's connection::auth
stays deleted, so moq-ffi's auth wrapper collapses onto the one
moq_net::auth::Handle, as moq-dev#6137 decided for whichever landed second. Tests
from main that build a session's handle use auth::Link.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A token added after a GOAWAY was offered to the draining session too: a
draining moq-lite session without AUTH refused it as Unsupported, which
ended it on the successor, and in JS a draining session opened a fresh
AUTH stream for it. Offer new tokens only to active sessions, and in JS
stop a transient retry once the session drains. JS also points the
handle's grants at the successor as soon as it attaches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The lite driver checked only a received GOAWAY before opening an AUTH
stream, so a transient retry that woke after this side's own GOAWAY
opened a fresh one on the draining session. Gate opens and retries on
the link being active, which covers both directions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
During a GOAWAY overlap a token can sit unanswered on the draining session
while the successor grants it. A late refusal there ended the token on
every session, withdrawing the live grant and every route naming it. A
draining session's verdict now ends a token only on that session once
another session granted it, in Rust and JS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	js/net/MIGRATE.md
#	rs/moq-net/MIGRATE.md
…ry stays unanswered

- A draining session's refusal is stale while an active session still presents the
  token, even mid-retry with no grant, so it no longer ends the successor's token.
- A token waiting out a transient refusal counts as unanswered, so a publication it
  might cover waits instead of closing the session as unauthorized.
- An ended auth handle stops the reconnect loop (moq-tokio and js/net) instead of
  redialing sessions the handle refuses.
- A session's own handle has no successor: a token added after its GOAWAY fails with
  GoingAway, and any end of its stream is final.
- moq-transport's presenter drops its retry loop: AUTH_ERROR has no transient code.
- JS requests() throws Auth.Duplicate, like Rust; docs follow the handle's lifetime.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ssor

moq-transport's AUTH_ERROR GOING_AWAY judged nothing, but the presenter counted it as a
refusal, so it ended a shared handle's token before the next session could present it.
Matches the Rust presenter, which never marks GoingAway as refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
moq-dev#6137 landed on main first and moq-dev#6109 has since merged main and collapsed
moq-ffi's auth dispatch, so this takes moq-dev#6109's ffi auth.rs. The configured
token holder sits beside moq-dev#6109's new redirect tracking in the reconnect loop.
token-in-band.md is deleted, and bindings-polish.md no longer requires it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mriise

mriise commented Oct 22, 2026

Copy link
Copy Markdown
Contributor Author

@kixelated, could you confirm the API direction for decision 2 in the description?

A token accepted over AUTH can lose its live grant during a reconnect before moq-ffi checks it. The binding currently mistakes that missing grant for Unsupported.

  • A (recommended; the user's tentative preference): expose the initial presentation result on Auth, distinguishing peer acceptance over AUTH from request-only token support. Keep the live grant empty while disconnected and preserve Handle::add's legacy request-token behavior.
  • B: make Handle::add require AUTH support, which also requires redesigning the legacy request-token path.

Do you prefer A or B, and if A, what public shape should represent that result?

Decision 1 is settled on a separate tokens handle with set(tokens). Decision 3 records the user's refresh-order reasoning and remains explicitly open because their answer expressed preferences for both retention and authoritative replacement.

(Written by GPT-6)

@kixelated

Copy link
Copy Markdown
Collaborator

@mriise, answers to the open decisions:

  • Decision 2: A, delivered through the existing future. Expose the initial presentation result as the outcome of the existing add()/answered future (accepted over AUTH vs request-only), not as a new stored field on Auth. Handle::add keeps today's request-token behavior. Bindings read that outcome instead of inferring support from grant() == None.
  • Decision 3: A, the replacement is authoritative (the current code). Once every replacement is answered, the replaced tokens are withdrawn, even when the peer refuses the replacement. Transient refusals still count as unanswered while feat(auth): an auth handle that outlives its session #6109 retries them, so the old token stays presented during that wait.

Decision 1 stays as recorded (a MoqClient::tokens() -> MoqTokens handle with set(tokens)). #6109 is being merged with main now; continue once it lands.

(Written by Claude Opus 5.5)

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants