MusePi

Auth Broker and Auth Gateway

The auth broker and auth gateway are two cooperating HTTP services that move OAuth refresh tokens and provider access tokens off developer laptops and into a single broker host.

Transport security between operator, broker, and gateway is delegated to the operator (Tailscale / Wireguard / reverse proxy + TLS). Every endpoint except /v1/healthz (broker) and /healthz (gateway) requires a bearer token.

Source: packages/ai/src/auth-broker/, packages/ai/src/auth-gateway/, packages/coding-agent/src/cli/auth-broker-cli.ts, packages/coding-agent/src/cli/auth-gateway-cli.ts, packages/coding-agent/src/session/auth-broker-config.ts.

Data flow

                ┌────────────────────────────────────────────────────────────┐
                │ broker host                                                │
                │                                                            │
  developer ──▶ │  ┌──────────────────────────┐    ┌────────────────────┐    │
  laptop /      │  │  musepi auth-broker serve   │◀──▶│  SQLite agent.db    │    │
  CI / robomp   │  │  - holds refresh tokens  │    │  (canonical writer)│    │
                │  │  - background refresher  │    └────────────────────┘    │
                │  │  /v1/{snapshot,refresh,…}│                              │
                │  └─────────┬────────────────┘                              │
                │            │  bearer ($CONFIG_DIR/auth-broker.token)       │
                │            ▼                                               │
                │  ┌──────────────────────────┐                              │
                │  │  musepi auth-gateway serve  │  RemoteAuthCredentialStore   │
                │  │  /v1/{chat,messages,…}   │  receives snapshot stream,   │
                │  │  /v1/usage,/v1/models    │  refreshes credentials by id │
                │  │  /v1/credentials/check   │  via the broker on expiry    │
                │  └─────────┬────────────────┘                              │
                └────────────┼───────────────────────────────────────────────┘
                             │  bearer ($CONFIG_DIR/auth-gateway.token)
                             ▼
                  gateway clients
                  (llm-git, macOS widget, robomp containers, IDE plugins, …)
                                │
                                ▼ provider request with broker-resolved credential
                  api.anthropic.com / api.openai.com / …

The broker is the only writer of OAuth refresh tokens. Clients (including the gateway itself) load a redacted snapshot in which every refresh field has been replaced with REMOTE_REFRESH_SENTINEL; when an access token expires the client calls POST /v1/credential/:id/refresh and the broker performs the refresh server-side. RemoteAuthCredentialStore rejects local replace/upsert/delete-by-provider mutations, with errors pointing at musepi auth-broker login / musepi auth-broker logout.

auth-broker

CLI

musepi auth-broker serve     [--bind=host:port]                    # boot the broker
musepi auth-broker token     [--regenerate] [--json]               # print or rotate the bearer token
musepi auth-broker login     [<provider>] [--via=user@host] [--dry-run]
musepi auth-broker logout    [<provider>]
musepi auth-broker list      [--json]
musepi auth-broker import    <file|dir> [--provider=<id>] [--include-disabled] [--dry-run] [--json]
musepi auth-broker migrate   --from-local [--include-oauth] [--include-env] [--dry-run] [--json]
musepi auth-broker status    [--json]

Endpoints

Method Path Auth Purpose
GET /v1/healthz none Liveness + version
GET /v1/snapshot bearer Redacted snapshot (refresh tokens replaced by sentinel)
GET /v1/snapshot/stream bearer SSE snapshot stream with delta events and keepalives
POST /v1/credential bearer Upsert one OAuth or API-key credential
POST /v1/credential/:id/refresh bearer Force-refresh one OAuth credential
POST /v1/credential/:id/disable bearer Disable one credential with a recorded cause
GET /v1/usage bearer Aggregate UsageReport[] across credentials

Requests use Authorization: Bearer <token>. The server compares against an in-memory token allow-list; the gateway’s implementation uses a timing-safe comparison.

Codex block-scope compatibility

Clients that understand per-meter Codex blocks send OMP-Auth-Broker-Capabilities: codex-meter-block-scopes. Snapshot responses then carry the canonical chat and spark scopes. Without that capability, the broker projects those rows to the legacy shared scope on the wire.

Local SQLite schema 7 keeps chat and spark as the canonical scopes exposed by current store APIs. It also maintains a physical shared compatibility mirror for pre-meter binaries that read agent.db directly. SQLite triggers derive that mirror’s deadline and update time independently from the meter rows, and copy a legacy process’s shared writes back to both meters. Current store APIs omit the physical mirror, so broker snapshots and model selection do not double-count it.

Clients released before this capability, including 17.1.4, receive the conservative shared projection until they are upgraded. Those clients are indistinguishable on the existing wire, so mixed-version deployments favor keeping a rate-limited credential blocked over allowing repeated provider requests and 429 responses.

Capability-dependent responses include Vary: OMP-Auth-Broker-Capabilities so intermediaries do not reuse one representation for another client. The encrypted client snapshot cache also uses a new format version: older cache files are ignored and fetched again, preventing legacy and meter-scoped representations from being mixed across client versions.

Background refresher

AuthBrokerRefresher iterates active OAuth credentials at refreshIntervalMs cadence and refreshes any within refreshSkewMs of expiry. Refreshes are single-flighted per credential id so a slow refresh cannot be retriggered. The refresher distinguishes:

auth-gateway

CLI

musepi auth-gateway serve   [--bind=host:port] [--no-auth]
musepi auth-gateway token   [--regenerate] [--json]
musepi auth-gateway status  [--json]
musepi auth-gateway check   [--strict] [--json]

Endpoints

Method Path Auth Purpose
GET /healthz none Liveness + version
GET /v1/usage bearer Aggregate UsageReport[] (proxied through AuthStorage)
GET /v1/models bearer Bundled-model catalog filtered to providers with credentials
GET /v1/credentials/check bearer Per-credential auth health probe
POST /v1/chat/completions bearer OpenAI Chat Completions wire format
POST /v1/messages bearer Anthropic Messages wire format
POST /v1/responses bearer OpenAI Responses wire format
POST /v1/pi/stream bearer Native pi-ai stream wire format

The model id is read from the top-level model field for foreign wire formats and from the pi-native request body for /v1/pi/stream. The gateway picks the first bundled Model<Api> matching that id, parses the inbound wire format into a musepi Context, resolves the provider credential from broker-backed AuthStorage, dispatches through streamSimple(), and re-encodes the result to the inbound format (SSE for streamed responses).

There is no raw provider passthrough path. All supported routes go through pi-ai provider logic so credential-specific request shaping, OAuth refresh-on-auth-error, and provider quirks stay centralized.

idleTimeout on the underlying Bun.serve is set to 255 s so long thinking-budget calls do not get killed by Bun’s default idle timeout.

Usage cache: server-side 5-min jitter + client-side 15 s single-flight

Two layers cache the aggregate provider-usage report. Both are intentional and stacked.

Server-side cache (broker AuthStorage)

AuthStorage caches each credential’s UsageReport in the broker’s SQLite store at a 5-minute per-credential TTL with ±25 % jitter. Anthropic and OpenAI rate-limit /usage aggressively per source IP, and a synchronized 5-credential fan-out trips 429s every cycle; the jitter decorrelates refresh times within a few cycles. On fetch failure the store keeps the last-good report for up to 24 h with a short jittered re-poll window — so a transient upstream blip never blanks out the widget.

Constants: USAGE_REPORT_TTL_MS = 5 * 60_000, USAGE_LAST_GOOD_RETENTION_MS = 24 * 60 * 60_000 (packages/ai/src/auth-storage.ts).

Client-side single-flight (RemoteAuthCredentialStore)

When the gateway (or any other broker client) calls fetchUsageReports() / getUsageReport(provider, credential), RemoteAuthCredentialStore coalesces concurrent calls into a single GET /v1/usage round-trip and caches the result for 15 s in memory.

The 15 s client window deliberately sits below the broker’s 5 min server cache, so almost every client poll is served from the broker’s already-cached value; the client cache exists to absorb the parallel fan-out generated by AuthStorage.#rankOAuthSelections into a single broker round-trip.

Client snapshot cache

discoverAuthStorage() persists the broker snapshot to ~/.musepi/cache/auth-broker-snapshot.enc after the initial /v1/snapshot fetch and after later broker-sourced full snapshots. The file is AES-256-GCM encrypted with SHA-256(OMP_AUTH_BROKER_TOKEN) and authenticated with the broker URL as additional data, so changing either the token or URL makes the cache unreadable. The file is written atomically with mode 0600.

Freshness is anchored to the broker-stamped snapshot.generatedAt, not local write time. Default TTL is 1 h (OMP_AUTH_BROKER_SNAPSHOT_TTL_MS); 0 disables cache reads and writes. A fresh cache is revalidated against a reachable broker with a 500 ms startup budget, so an imported, revoked, or rotated credential is visible to one-shot commands immediately. If revalidation fails because the broker is unavailable or slow, musepi starts from the cache and RemoteAuthCredentialStore continues normal SSE / long-poll synchronization in the background. Expired OAuth access tokens still refresh through POST /v1/credential/:id/refresh.

If the broker is down at boot and a fresh cache exists, startup succeeds from the cached snapshot. Authentication failures (401/403) are not masked by the cache; transient server errors fall back to it. If the cache is missing, expired, corrupt, written for a different URL, or encrypted with a different token, startup falls back to the live fetch and fails if the broker is unreachable.

Client account pools (routing, not authorization)

Broker clients can restrict their visible OAuth accounts by setting OMP_AUTH_BROKER_ACCOUNT_POOL_FILE to a JSON file. The file maps provider IDs to exact identityKey values from the broker snapshot protocol:

{
  "anthropic": ["email:alice@example.com|org:org-team"],
  "openai-codex": []
}

identityKey is the token-free identity field already carried by each authenticated /v1/snapshot credential entry. Operator tooling should project only provider and identityKey; it must not retain or print the accompanying credential payload. A dedicated account-listing CLI is intentionally outside this routing feature’s scope.

SDK hosts can supply the same provider-to-identity mapping as accountPool in discoverAuthStorage() or RemoteAuthCredentialStore. An explicit programmatic pool takes precedence over the environment file.

The file is parsed once when broker-backed auth storage starts. An unreadable file, malformed JSON, or invalid provider entry aborts initialization rather than silently broadening the pool. Full snapshots, SSE updates, refresh responses, and aggregate usage are filtered consistently. For a provider named in the pool, aggregate reports are returned only when they can be attributed to a visible OAuth identity; reports attributable only to an API key or lacking matching identity metadata fail closed. The encrypted snapshot cache remains a raw broker snapshot so trusted processes sharing that cache can apply different pools.

This is a trusted-client routing policy, not an authorization boundary. The client still holds a broker bearer token, receives raw broker responses before applying its local view, and can call broker endpoints directly. Use server-side authorization—not account pools—when clients must be prevented from retrieving other credentials.

Operator opt-in

The broker is off unless OMP_AUTH_BROKER_URL (or auth.broker.url in config.yml) is set. When set, discoverAuthStorage in packages/coding-agent/src/sdk.ts swaps the local SQLite credential store for RemoteAuthCredentialStore and every API call resolves credentials through the broker.

Environment variables

Variable Purpose Required when
OMP_AUTH_BROKER_URL Base URL of the remote auth-broker (e.g. https://broker.tailnet:8765). Selecting this puts the client in broker mode — local SQLite is bypassed. Any time the musepi client should resolve credentials through a broker (and required by musepi auth-gateway serve).
OMP_AUTH_BROKER_TOKEN Bearer token used for every broker endpoint except /v1/healthz. When OMP_AUTH_BROKER_URL is set and no token is available from auth.broker.token or <config-dir>/auth-broker.token.
OMP_AUTH_BROKER_SNAPSHOT_TTL_MS Freshness window for the encrypted local snapshot cache. Default 3600000 (1 h); 0 disables cache reads and writes. Optional in broker mode.
OMP_AUTH_BROKER_SNAPSHOT_CACHE Path override for the encrypted local snapshot cache. Default ~/.musepi/cache/auth-broker-snapshot.enc (or XDG cache equivalent). Optional in broker mode.
OMP_AUTH_BROKER_ACCOUNT_POOL_FILE JSON file mapping provider IDs to OAuth identityKey values visible to this trusted client. Parsed once; invalid files abort initialization. API keys are unaffected. Optional in broker mode.

Resolution order in resolveAuthBrokerConfig():

  1. OMP_AUTH_BROKER_URL env (else auth.broker.url from config.yml, resolved through resolveConfigValue);
  2. OMP_AUTH_BROKER_TOKEN env (else auth.broker.token from config.yml, else <config-dir>/auth-broker.token);
  3. URL set but no token resolvable → hard error pointing at the token file path.

The gateway has no dedicated env vars — it inherits OMP_AUTH_BROKER_* because it is itself a broker client.

config.yml keys

Key Default Purpose
auth.broker.url unset Same as OMP_AUTH_BROKER_URL; env wins. Hidden from the settings UI. Values are resolved as a literal, an environment variable name, or !<shell command> to use trimmed stdout.
auth.broker.token unset Same as OMP_AUTH_BROKER_TOKEN; env wins. Values are resolved the same way.

Token files

Path Owner Mode
<config-dir>/auth-broker.token musepi auth-broker serve (created at first start) 0600 in a 0700 parent dir
<config-dir>/auth-gateway.token musepi auth-gateway serve (skipped under --no-auth) 0600 in a 0700 parent dir

<config-dir> resolves to ~/.musepi/ (respecting PI_CONFIG_DIR).

Interaction with the local API-key resolution order

The broker only owns OAuth credentials and provider-API-key credentials that were uploaded to it. The standard credential ladder in models.md (Auth and API key resolution order) is preserved, with one addition committed alongside the gateway:

See also