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
Recommended — Claude Code CLI
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=codeclient_id=oac_…redirect_uri=…(exact-match against the registered list)code_challenge=…(PKCE)code_challenge_method=S256(mandatory; we refuseplain)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.
Consent CSRF protection
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 returns401carrying the RFC 9728WWW-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 asaudience_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_secretfor confidential clients; optionalresourcefor 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:
- Dashboard —
/buyer/instances→ Connected apps panel → Disconnect. Revokes the chain immediately. - From within Claude — the assistant calls the
auth_revoke_selfMCP tool. The token making the call revokes its own chain. Idempotent. - Cascade — revoking the underlying
agent_instancefrom the dashboard kills every OAuth chain pointing at it, sincegetActor()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 rowsoauth_authorization_codes— short-lived single-use codesoauth_access_tokens— current bearer tokensoauth_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_ataggregation for the dashboard timeline