bitroadbitroad
API reference

OAuth 2.1

Discovery → DCR → authorize → token flow for MCP clients.

Agent developers

bitroad is an OAuth 2.1 authorization server and a protected resource — both at the same origin. MCP clients (Claude Code, Cursor, MCP SDK apps) connect by following the standard discovery → register → authorize → token dance. No copy-paste of long-lived API keys.

Connecting a buyer agent

claude mcp add --transport http bitroad https://app.bitroad.ai/api/v1/mcp

The CLI opens a browser to /oauth/authorize, the user clicks Allow on the consent screen (the same one this implementation serves to every OAuth client), control returns to the terminal, the token is stored, and the agent is usable in the next claude chat. The flow re-uses every server-side piece documented below — consent, agent creation, delegation, refresh-token rotation, audit.

Claude.ai (web) — works (needs Cloudflare bots allowed)

The Claude.ai web "Add custom connector" flow completes the full OAuth handshake (DCR → authorize → token exchange all return 200, the DB records a consumed code and a minted access+refresh pair) and the authenticated MCP session then works.

It previously failed with the symptom of anthropics/claude-code#46140 / claude-ai-mcp#438: OAuth completed, then the connector reported "Authorization with the MCP server failed" — indistinguishable in origin logs from the upstream client bug those issues describe. On this deployment the cause was not the client: Cloudflare Bot Fight Mode was challenging the authenticated MCP request, which the web connector issues from Anthropic's datacenter egress IPs. Desktop clients (CLI, Cursor) call from the user's residential IP and were never challenged — the tell that it was the edge, not claude.ai. Allowing bots for the app.bitroad.ai host unblocked it (fixed 2026-07-03). Prefer a scoped WAF skip on the MCP/OAuth paths over disabling bot protection zone-wide.

Cursor, MCP SDK apps, anything else spec-compliant

Same https://app.bitroad.ai/api/v1/mcp URL; the client discovers our OAuth metadata automatically via the WWW-Authenticate 401 hint.

Discovery

Two well-known documents drive client setup:

URL Purpose
GET /.well-known/oauth-protected-resource RFC 9728. Tells the client what is protected (/api/v1/mcp) and which authorization server to use.
GET /.well-known/oauth-authorization-server RFC 8414. Lists the /authorize, /token, /register URLs and supported features.

The MCP route itself advertises this on 401 via the WWW-Authenticate: Bearer resource_metadata="..." header, so a client hitting /api/v1/mcp without credentials auto-discovers the flow.

Dynamic Client Registration (DCR)

POST /api/oauth/register — RFC 7591. Public endpoint; any client may register itself. Body:

{
  "client_name": "claude.ai",
  "redirect_uris": ["https://claude.ai/api/mcp/auth_callback"],
  "token_endpoint_auth_method": "none",
  "grant_types": ["authorization_code", "refresh_token"]
}

Returns:

{
  "client_id": "oac_XXXXX…",
  "client_secret": "ocs_XXXXX…",
  "client_id_issued_at": 1779384000,
  "client_secret_expires_at": 0,
  "client_name": "claude.ai",
  "redirect_uris": [...],
  ...
}

client_secret is omitted for public clients (token_endpoint_auth_method = "none", the PKCE-only path).

grant_types is optional and defaults to ["authorization_code", "refresh_token"]. Only those two values are accepted; anything else is refused at registration. What a client registers here is enforced — see Registered grant types below.

Accepted redirect_uris (validated by isSafeRedirectUri in apps/web/src/app/api/oauth/register/route.ts): public https:// URLs, and — per RFC 8252 — plaintext http:// to a loopback address (localhost, 127.0.0.0/8, ::1) in every environment. That last case is how desktop MCP clients register: Claude Code CLI / Cursor / the MCP SDK spin up http://127.0.0.1:PORT/callback. The AS never fetches a redirect_uri (the browser does the redirect), so a loopback target is not an SSRF/open-redirect risk. https:// to a private/loopback host and http:// to any non-loopback host are refused in production; javascript:/data:/file:/other schemes are always refused.

Authorization flow

Client                    bitroad                          User
  │                          │                              │
  ├── /authorize?...PKCE...─► │                              │
  │                          ├── 302 /sign-in if no session ►│
  │                          ◄────────────── logs in ────────┤
  │                          │── renders consent screen ──── │
  │                          ◄─── Allow + picks agent ───────┤
  │                          │  (mints code, hashes it)      │
  ◄── 302 redirect_uri?code=… │                              │
  ├── POST /token (code + verifier) ─►                       │
  ◄── { access_token, refresh_token } ───                    │
  ├── POST /api/v1/mcp (Bearer access_token) ─►              │
  ◄── tool result                                            │

Required params on GET /oauth/authorize:

  • response_type=code
  • client_id=oac_…
  • redirect_uri=… (exact-match against the registered list)
  • code_challenge=… (PKCE)
  • code_challenge_method=S256 (mandatory; we refuse plain)
  • state=… (recommended; echoed back unchanged)
  • resource=… (RFC 8707; SHOULD be the canonical MCP URL — https://app.bitroad.ai/api/v1/mcp. Carried through consent and bound to the issued tokens — see Audience binding below.)

The consent screen lists the user's existing non-revoked agents and defaults to Create a new agent named after the client. New OAuth- bound agents ship with a strict read-only delegation (per_tx_cap=0, daily_cap=0) — they can browse and read, but every spend gate refuses until the user issues a fresh delegation from /buyer/instances/<id>/delegation.

The Allow / Deny form is a plain HTML POST to /oauth/authorize/decision, not a server action — the form has to work behind the Cloudflare Tunnel, which rewrites Origin/Referer enough to trip Next.js's server-action CSRF check. The handler authenticates from the Auth.js session cookie, and that cookie is SameSite=Lax and scoped to the parent domain (AUTH_COOKIE_DOMAIN). Lax permits exactly this shape of request, and parent-domain scoping means any *.bitroad.ai host — including one we don't operate, now or in future — is same-site for the cookie. Without a check of its own, a forced-consent form on such a host could mint an authorization code bound to the victim's account for an attacker-registered client.

Two independent gates sit in front of the mint. Either alone stops the attack; both are checked on every POST, allow and deny alike.

1. Origin allowlist. isAllowedFormOrigin (packages/core/src/server/urls.ts) compares the request's Origin — falling back to the Referer origin only when Origin is absent — against allowedFormOrigins(): the origins of APP_URL, MARKETING_URL, BUYER_URL, SELLER_URL, and AUTH_URL, deduped, with unset vars falling back to APP_URL (so dev and single-host deploys allow just that one origin). Neither header present is a rejection, not a pass — "absent means trusted" is the classic bypass — as is the literal Origin: null a sandboxed iframe or cross-origin redirected POST sends.

The comparison is deliberately against the configured zone origins and not against the request Host: behind the tunnel the Host the app sees is the internal container name (web:3000), so a Host match would fail for every legitimate request in production.

2. Consent nonce. The consent page mints a short-lived HMAC with the existing action-token helper (packages/core/src/server/auth/action-tokens.ts) under purpose oauth_consent, TTL 15 minutes, signed with AUTH_SECRET. It carries the principal id and is fingerprinted over (client_id, redirect_uri, resource), and rides the form as a hidden consent_token field. The decision route recomputes the fingerprint from the posted params and verifies the signature with timingSafeEqual, requiring the embedded principal id to equal the session principal.

So a nonce is useless off the screen it was rendered on: it can't be replayed for a different client or return address, can't be used by another principal, and expires. A cross-site forgery can copy every hidden OAuth input but cannot produce the signature.

Failures 303 to /oauth/authorize/error with reason=bad_origin or reason=consent_expired; the user is told to restart the connection from the app itself. A stale consent tab (>15 min) hits consent_expired and recovers by re-running the flow.

Tests: apps/web/tests/unit/oauth-consent-csrf.test.ts (origin allowlist + nonce binding) and apps/web/tests/integration/auth/oauth-consent-decision.test.ts (route-level accept/reject).

Audience binding (RFC 8707)

Clients SHOULD send resource=https://app.bitroad.ai/api/v1/mcp (the canonical MCP URL) on GET /oauth/authorize. The value rides through the consent screen as a hidden field and is forwarded by the consent decision handler — which re-validates it, and every other OAuth param, against the hidden-input replay — into createAuthorizationCode, where it is stored on the issued authorization code as oauth_authorization_codes.audience.

When the code is exchanged at /token, the stored audience is copied onto both minted tokens (oauth_access_tokens.audience and oauth_refresh_tokens.audience) and propagated forward to every token minted on the same chain by a refresh.

If the client also resends resource on POST /api/oauth/token, exchangeAuthorizationCode re-validates it against the value stored on the code: a mismatch is rejected as 400 invalid_grant (the on-wire error_description is code_redirect_uri_mismatch). A resource absent at /token is accepted and binds nothing further — the value captured at /authorize already governs.

Resolve-time re-check. The bound audience is re-checked every time an access token is resolved. After resolveAccessToken returns the row, getActor compares token.audience against canonicalMcpUrl() — the single source of truth for the canonical string (https://app.bitroad.ai/api/v1/mcp; lowercase, no trailing slash, default port) — via the pure evaluateTokenAudience helper. The comparison stays in getActor (not in resolveAccessToken) so the oauth service remains side-effect-free and the decision is unit-testable. canonicalMcpUrl() derives from APP_URL, the same origin that governs the resource value bound at /authorize, so the two can never drift (a stray trailing slash or explicit default port would otherwise read as a false mismatch).

  • Audience equals canonicalMcpUrl() → the token resolves normally.
  • Audience is any other value → resolution fails with audience_mismatch; the MCP route returns 401 carrying the RFC 9728 WWW-Authenticate: Bearer … resource_metadata="…" header, so the client re-discovers the correct resource.
  • Audience is NULL (legacy tokens minted before the field was populated) → rejected as audience_mismatch. Strict since 2026-07-07 — the grace window is over. A client holding a legacy token gets the same RFC 9728 401 as any mismatch and simply re-runs the OAuth flow, which mints an audience-bound token; dead rows are pruned by the nightly TTL cleanup.

New tokens can no longer be minted audience-less: resource is optional for clients (RFC 8707), so when it's absent the token endpoint binds the pair to canonicalMcpUrl() at issue time — we are a single-resource server, every token we mint is for our MCP endpoint. A refresh re-copies the chain's audience through the same default, so legacy NULL-audience chains self-heal on their next refresh instead of forcing re-auth.

Token endpoint

POST /api/oauth/token — RFC 6749 §3.2. Body is application/x-www-form-urlencoded.

Grants supported:

  • authorization_code — code, code_verifier, redirect_uri, client_id (+ client_secret for confidential clients; optional resource for RFC 8707 audience re-validation — see Audience binding)
  • refresh_token — refresh_token, client_id (+ secret if confidential)

Successful response:

{
  "access_token": "br_oat_live_…",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "br_ort_live_…"
}

Registered grant types

RFC 7591 §2: grant_types on the registration is a restriction, not decoration. A client may only exercise the grants it registered for, and that is checked in two places against oauth_clients.grant_types, via the shared clientAllowsGrant() helper in packages/core/src/server/oauth/service.ts:

Where Behaviour
POST /api/oauth/token After client authentication, before dispatch. A grant the client didn't register for is 400 unauthorized_client (RFC 6749 §5.2).
GET /oauth/authorize + POST /oauth/authorize/decision A client not registered for authorization_code never reaches consent: the page renders "Grant type not registered", and the decision handler re-checks and refuses with ?reason=unauthorized_client rather than minting a code.

Ordering at /token matters: a grant this server doesn't implement at all (client_credentials, password, …) is still unsupported_grant_type, which is checked first — a client asking for something we don't do gets told that, not that it lacks permission. The per-client check runs after client authentication, so an unauthenticated caller can't probe which grants a client_id holds.

Empty grant_types means unrestricted, and there is no backfill migration. oauth_clients.grant_types is NOT NULL DEFAULT '{authorization_code,refresh_token}'::text[], so a NULL row cannot exist and every client registered normally already carries its real grant list. The only ambiguous case is an empty array, which could only be written by a pre-enforcement DCR body that sent "grant_types": [] — clients that worked fine before this check existed. Those are read as allow-all so the flip is backwards compatible, and registerClient now normalises an empty list to the default, so no new empty rows can appear.

One consequence worth knowing: an authorization_code-only client still receives a refresh_token in its token response (the mint path is unconditional), it just gets unauthorized_client if it ever tries to redeem it. Suppressing the mint would make refresh_token optional throughout TokenPair for no security gain — the redemption is what's gated.

Token model

Token Wire format TTL Notes
Access br_oat_live_<13>_<52> 1 hour hard Resolves to an agent_instance actor via getActor() — same shape as br_ik_*.
Refresh br_ort_live_<13>_<52> No calendar expiry. 180-day inactivity gate (last_used_at + 180d). Single-use; rotates on every refresh; reuse triggers chain revocation.

Refresh-token reuse detection (RFC 6749 §10.4): if a consumed refresh token is replayed — the canonical signal of theft — the entire chain (every access + refresh token sharing that chain_id) is revoked.

Revocation paths

There is no /oauth/revoke endpoint in v1. Three ways to disconnect:

  1. Dashboard — /buyer/instances → Connected apps panel → Disconnect. Revokes the chain immediately.
  2. From within Claude — the assistant calls the auth_revoke_self MCP tool. The token making the call revokes its own chain. Idempotent.
  3. Cascade — revoking the underlying agent_instance from the dashboard kills every OAuth chain pointing at it, since getActor() refuses revoked instances.

The auth_whoami MCP tool is the read-side companion: it returns the principal email/role, the agent name, the OAuth client name, and the issued / last-used timestamps. The user can ask the assistant "what bitroad connection am I using?" and get a useful answer.

Schema

Four tables:

  • oauth_clients — DCR rows
  • oauth_authorization_codes — short-lived single-use codes
  • oauth_access_tokens — current bearer tokens
  • oauth_refresh_tokens — rotation chains

Rate limiting

Both public OAuth endpoints are throttled per client IP via shared Postgres token buckets:

  • POST /api/oauth/token — 30 burst, 1 every 2s sustained (ip.oauth_token)
  • POST /api/oauth/register — 10 burst, ~1/min sustained (ip.oauth_register)

Exhausted buckets return 429 with { "error": "slow_down" } and a Retry-After header. The IP comes from cf-connecting-ip (Cloudflare Tunnel in prod) falling back to the first x-forwarded-for hop.

Not supported

The implementation does not provide:

  • RFC 7009 revoke endpoint
  • RFC 7662 introspection endpoint
  • Granular OAuth scopes (a single implicit "act as agent" scope applies)
  • Per-client logo on the consent screen
  • Per-chain last_used_at aggregation for the dashboard timeline