Auth-specific code: 21 lines · Total example: 59 lines · SDK: ts-sdk 0.3.0
When your MCP server needs to call a third-party API on the user's behalf — GitHub, Slack, Google Workspace, Notion — it does an RFC 8693 Token Exchange against a Broker-backed Resource. Authplane brokers the OAuth dance with the provider, holds the user's encrypted refresh token, and vends the provider's native access token. Your MCP server uses that token to call the upstream API.
This example shows the SDK's outbound Token Exchange call and how to
handle ConsentRequiredError — the typed exception the SDK raises when
the user hasn't connected the provider yet. Its consentUrl points the
human at /connect/{provider} (or /authorize?resource=…, depending
on which bound failed). Everything needed to drive the exchange and
catch the error fits inside // authplane:begin / // authplane:end
markers in agent.ts.
For the conceptual model, see docs/concepts/broker-vs-mint.md — the "Fronting — a relationship between a Mint and a Broker" section has the end-to-end sequence diagram.
Self-contained. This example does not depend on tier-01..03. The standalone
agent.tsshape is purely for readability — in production the same SDK calls live inside an MCP server's tool handler.
- How to enable the Token Exchange grant on Authplane via
AUTHPLANE_TOKEN_EXCHANGE_ENABLED=true(seedocs/reference/env-vars.md) - How to register a Broker provider
and front it with a
backend_kind=brokerResource that the agent can target byresource=<slug> - How to gate which acting clients may exchange against a Broker resource
via
policy.exchange.allowed-clients(HTTP API anchor) - How the SDK's
AuthplaneClient.exchange(...)issues an RFC 8693 call and why a missingbroker_grantsrow collapses intoConsentRequiredErrorwith aconsentUrlyou hand to the user - Where the upstream refresh token lives — inside the AS in the
encrypted
broker_grantsrow, never on the wire; every exchange triggers a transparent upstream refresh under the hood when the access token has expired
| Time to run | ~2 minutes (first build is ~90s, subsequent runs are seconds) |
| Prereqs | Docker 24+, docker compose, curl, jq, Node.js 22+ (only if you run outside Docker) |
| SDK | @authplane/sdk 0.3.0 (npm) |
| Wire grant | urn:ietf:params:oauth:grant-type:token-exchange (RFC 8693) |
| Stops at | the agent catching ConsentRequiredError and printing the consentUrl — there is no real upstream provider in this example |
cp .env.example .env
make run
make verifymake run brings up authserver on :9000/:9001 with the Token Exchange
grant enabled. make verify registers a placeholder Broker provider, a
Broker-kind Resource, a Mint actor-MCP Resource, and an OAuth client; wires
the client through policy.runtime.client_ids and
policy.exchange.allowed-clients; then runs the agent as a one-shot
container. The agent acquires a client_credentials subject token, calls
AuthplaneClient.exchange(...) against the Broker resource, catches the
ConsentRequiredError, and prints the consentUrl. make clean tears
everything down and removes the .env file the run target created.
The make verify script automates every step below; the bullets here
describe what's happening so you can reproduce the flow by hand.
If you want to run the curl examples manually instead of via
make verify, first load the env vars and capture client credentials as you go:set -a; source .env; set +a # exports AUTHPLANE_ADMIN_API_KEY etc.Step 5 emits
client_idandclient_secretin its response; assign each to a shell variable (CLIENT_ID=...,CLIENT_SECRET=...) before steps 6 and 7 use them.
-
Start authserver.
make runbrings the AS up. TheAUTHPLANE_TOKEN_EXCHANGE_ENABLED=trueenv var (seedocs/reference/env-vars.md) advertisesurn:ietf:params:oauth:grant-type:token-exchangein the discovery document and turns on the dispatcher. Wait for the AS to answer:curl -fsS http://localhost:9000/.well-known/oauth-authorization-server
-
Register the Broker provider. A Broker provider is the AS-side record of an upstream OAuth provider's authorize/token URLs and the client_secret env-var name. Verified against
POST /admin/broker-providers:curl -sS -X POST http://localhost:9001/admin/broker-providers \ -H "Authorization: Bearer $AUTHPLANE_ADMIN_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "slug": "github", "display_name": "GitHub OAuth", "protocol": "oauth", "config_data": { "client_id": "Iv1.fakeoauthapp_abc123", "client_secret_ref": "AUTHPLANE_ADMIN_API_KEY", "authorize_url": "https://github.example.invalid/login/oauth/authorize", "token_url": "https://github.example.invalid/login/oauth/access_token" } }'
The example uses fake URLs because the agent never reaches the upstream; the flow stops at
consent_required. Connecting a real provider is the subject of the Connecting upstream providers guide. -
Register a Broker-kind Resource. This is the slug the agent passes as
resource=<slug>in the exchange call. Verified againstPOST /admin/resources:curl -sS -X POST http://localhost:9001/admin/resources \ -H "Authorization: Bearer $AUTHPLANE_ADMIN_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "slug": "github", "uri": "https://github.example.invalid", "backend_kind": "broker", "broker_provider_slug": "github", "display_name": "Demo upstream broker", "scopes": [{"name": "repo", "description": "repo access", "upstream": "repo"}] }'
-
Register the actor-MCP Mint Resource. The agent's
client_credentialssubject token carries this resource's URI asaud. The Broker dispatch's agent-attestation gate mapsreq.client_idback to this resource viapolicy.runtime.client_ids(next step). Same DTO, this time withbackend_kind=mint:curl -sS -X POST http://localhost:9001/admin/resources \ -H "Authorization: Bearer $AUTHPLANE_ADMIN_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "slug": "demo-mcp-tier04", "uri": "http://localhost:8080/mcp", "backend_kind": "mint", "display_name": "Demo actor MCP", "scopes": [{"name": "repo", "description": "actor-side repo scope"}] }'
-
Register an OAuth client with both grants. The client needs
client_credentials(to acquire the subject token) andurn:ietf:params:oauth:grant-type:token-exchange(to call the exchange). Seedocs/reference/http-api.md#http-admin-clients-create:curl -sS -X POST http://localhost:9001/admin/clients \ -H "Authorization: Bearer $AUTHPLANE_ADMIN_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "client_name": "demo-broker-upstream-client", "grant_types": ["client_credentials", "urn:ietf:params:oauth:grant-type:token-exchange"], "token_endpoint_auth_method": "client_secret_basic", "scope": "repo" }'
-
Wire both policies. The actor linkage is
policy.runtime.client_idson the Mint resource (anchor:http-admin-resources-slug-policy-runtime-client-ids-create); the exchange ACL ispolicy.exchange.allowed-clientson the Broker resource (anchor:http-admin-resources-slug-policy-exchange-allowed-clients-create):curl -sS -X POST \ "http://localhost:9001/admin/resources/demo-mcp-tier04/policy/runtime/client-ids" \ -H "Authorization: Bearer $AUTHPLANE_ADMIN_API_KEY" \ -H "Content-Type: application/json" \ -d "{\"client_id\": \"$CLIENT_ID\"}" curl -sS -X POST \ "http://localhost:9001/admin/resources/github/policy/exchange/allowed-clients" \ -H "Authorization: Bearer $AUTHPLANE_ADMIN_API_KEY" \ -H "Content-Type: application/json" \ -d "{\"client_id\": \"$CLIENT_ID\"}"
-
Run the agent. The agent acquires a
client_credentialstoken against the Broker resource, then callsAuthplaneClient.exchange(...)against it. Because nobroker_grantsrow exists for the (user, provider) pair, the AS repliesconsent_requiredwith aconsent_url; the SDK surfaces aConsentRequiredErrorwhoseconsentUrlfield carries that URL. Seedocs/reference/http-api.md#http-public-oauth-token:docker compose run --rm \ -e AUTHPLANE_ISSUER=http://authserver:9000 \ -e AUTHPLANE_CLIENT_ID="$CLIENT_ID" \ -e AUTHPLANE_CLIENT_SECRET="$CLIENT_SECRET" \ -e UPSTREAM_RESOURCE=github \ -e BROKER_RESOURCE_URI=https://github.example.invalid \ agent
Expected output:
[agent] consent_required: visit http://authserver:9000/connect/github?...The agent exits
2— this is the expected branch for tier 04 because the user has not connected the upstream. In production the operator handsconsentUrlto the human, who runs the/connect/{provider}flow once, and the nextexchange(...)call returns the vended upstream token.
A naive client that ignores the consent path:
- const upstream = await ap.exchange({
- subjectToken: subject.accessToken,
- scope: "repo",
- resources: ["github"],
- });
- // -> throws an opaque OAuth error; nothing to tell the userThe same client with ConsentRequiredError handling:
+ try {
+ const upstream = await ap.exchange({
+ subjectToken: subject.accessToken,
+ scope: "repo",
+ resources: ["github"],
+ });
+ // -> upstream.accessToken is the vended upstream provider token
+ } catch (e) {
+ if (e instanceof ConsentRequiredError) {
+ console.log(`Consent required, user must visit: ${e.consentUrl}`);
+ process.exit(2);
+ }
+ throw e;
+ }The full auth-specific footprint lives inside the // authplane:begin /
// authplane:end markers in agent.ts. Run
go run ./tools/loccount examples/typescript/04-broker-upstream from the
repo root to see the count.
A Broker resource is the AS-side surface for an upstream OAuth provider
(GitHub, Google, Slack, ...). Unlike Mint resources, the AS does not sign a
new JWT — it returns the actual upstream provider's access token, refreshed
on demand using the encrypted broker_grants.refresh_token it stored when
the user ran /connect/{provider} the first time.
Every brokered vend has to clear the three-bound check (see
docs/guides/upstream-providers/connecting-providers.md):
- Consent grant — the user has a
consent_grantsrow for(user_id, agent_client_id, actor_mcp_resource_id). Without it, the AS returnsconsent_requiredwithconsent_urlpointing at/authorize?resource=<actor_mcp>. - Broker grant — the user has a
broker_grantsrow for(user_id, broker_provider_id)and the requested scopes are ⊆scopes_granted. Without it (or with a strict subset), the AS returnsconsent_requiredwithconsent_urlpointing at/connect/{provider}. - Operator gate — the acting
client_idis inpolicy.exchange.allowed_client_ids(or the list is empty). Without it the response isaccess_denied.
This example exercises the first failure mode end-to-end: the verify script
sets up everything except the broker_grants row, so the agent
deterministically lands on the consent_required branch and the
ConsentRequiredError.consentUrl field carries the URL the operator would
hand to a human.
On the SDK side, AuthplaneClient.exchange({subjectToken, scope, resources})
builds the RFC 8693 form body, signs it with the client credentials the
constructor was given, and posts it to POST /oauth/token (anchor:
http-public-oauth-token).
The response is parsed; the consent_required shape ({error, consent_url, cause})
is mapped to a typed ConsentRequiredError (@authplane/sdk/dist/auth/errors.d.ts:30)
whose consentUrl field comes from the wire consent_url (snake-case on
the wire, camelCase in TypeScript — @authplane/sdk/dist/auth/errors.js:115-121).
Every other OAuth error code is mapped to its own typed error class by the
same mapOAuthError helper.
- Connect a real upstream provider end-to-end: Guides → Connecting upstream providers
- Pick the right exchange shape (brokered vend vs delegation vs scope narrowing): Guides → Token Exchange grant
- Topology reference for Broker-fronted Mint resources:
docs/topologies/folded-resource.md— the canonical map for how Broker and Mint resources combine in real deployments.
To build the AS from this checkout rather than pulling
authplane/authserver:latest, follow the LOCAL BUILD ESCAPE
HATCH comment block in
../../_shared/docker-compose.authserver.yml.
Mirror the change in this example's docker-compose.yml (which inlines
the same service definition) — replace the image: line with the
build: block shown in the shared file.