Wire up the Token Vault
TL;DR — Four steps: turn on data encryption at rest, register broker providers (GitHub, Slack, Google, custom), configure the Connect flow, then vend upstream tokens from your MCP tools via RFC 8693 token exchange. The SDK translates upstream consent gaps into MCP JSON-RPC
-32042elicitation so your tool code stays clean. The “Token Vault” name was retired in v0.1.0-rc1; the unified Resource model withmint/brokerbackends is what actually implements it.
The four steps at a glance
- Encryption —
data_encryptionblock (AES master key or Vault Transit). - Broker providers — register each upstream OAuth provider you’ll vend from.
- Connect flow —
connect.*config for the browser-based user grant flow. - Vend from tools —
client.exchange()in your handler code.
Each step is a few lines. Full source: authserver/docs/how-to/upstream-connections.md.
Step 1 — Data encryption at rest
Upstream refresh grants live in broker_grants — never plaintext on disk. Pick a driver:
AES master key (simpler)
data_encryption:
driver: aes_master
aes_master:
key_env: AUTHPLANE_DATA_ENCRYPTION_KEY
export AUTHPLANE_DATA_ENCRYPTION_KEY=$(openssl rand -hex 32)
HashiCorp Vault Transit (enterprise)
data_encryption:
driver: vault_transit_encrypt
vault_transit_encrypt:
address: https://vault:8200
auth_method: approle
mount_path: transit
key_name: authserver-data
approle:
role_id_env: VAULT_APPROLE_ROLE_ID
secret_id_env: VAULT_APPROLE_SECRET_ID
Plaintext never touches the AuthPlane process. Details in Operate: Vault Transit.
Step 2 — Register broker providers
Each broker_providers: entry is a third-party OAuth upstream (or API key / service account) that AuthPlane can vend from. Manage via the Admin API in production; YAML below is convenient seed data on first boot.
broker_providers:
- slug: github
display_name: GitHub
protocol: oauth
config_data:
client_id: "your-github-oauth-app-client-id"
client_secret_ref: CONNECTOR_GITHUB_SECRET
authorize_url: https://github.com/login/oauth/authorize
token_url: https://github.com/login/oauth/access_token
- slug: slack
display_name: Slack
protocol: oauth
config_data:
client_id: "your-slack-app-client-id"
client_secret_ref: CONNECTOR_SLACK_SECRET
authorize_url: https://slack.com/oauth/v2/authorize
token_url: https://slack.com/api/oauth.v2.access
response_format: form
- slug: google
display_name: Google
protocol: oauth
config_data:
client_id: "your-google-oauth-client-id"
client_secret_ref: CONNECTOR_GOOGLE_SECRET
authorize_url: https://accounts.google.com/o/oauth2/v2/auth
token_url: https://oauth2.googleapis.com/token
extra_auth_params:
access_type: offline # Google refresh token
Registering the upstream OAuth app (GitHub example):
- GitHub → Settings → Developer settings → OAuth Apps → New.
- Set callback URL to
http://localhost:9000/connect/github/callback(or your production URL). - Copy client_id + secret into the config.
Repeat per provider. Each broker provider is paired with one or more Broker resources: that name the fine-grained scopes AuthPlane may vend. Full example: authserver/examples/configs/resources-broker-providers.yaml.
Already-seeded providers: YAML broker_providers: is only applied when a provider doesn’t already exist. Subsequent edits don’t propagate — use PATCH /admin/broker-providers/{id} or the Admin UI.
Step 3 — Connect flow config
connect:
state_secret: AUTHPLANE_CONNECT_STATE_SECRET
allowed_return_urls:
- http://localhost:*
- https://myapp.example.com/*
redirect_base_url: http://localhost:9000
export AUTHPLANE_CONNECT_STATE_SECRET=$(openssl rand -base64 32)
allowed_return_urls prevents open-redirect attacks — only URLs matching the patterns can be used as return_url on /connect/{provider}?return_url=....
Step 4 — Vend from your tool code
from authplane.oauth import TokenExchangeOptions
from mcp.server.auth.middleware.auth_context import get_access_token
@mcp.tool()
async def read_calendar() -> dict[str, str]:
require_scope("tools/read_calendar")
inbound = get_access_token()
downstream = await auth.client.exchange(TokenExchangeOptions(
subject_token=inbound.token,
resources=("google",), # broker provider slug
scope="https://www.googleapis.com/auth/calendar",
))
# downstream.access_token is a real Google Calendar token, expires_in ~1h
return {"token_type": downstream.token_type,
"expires_in": str(downstream.expires_in)}server.addTool({
name: "read_calendar",
parameters: z.object({}),
canAccess: requireScopes("tools/read_calendar"),
execute: async () => {
const inbound = /* your framework's access-token accessor */;
const downstream = await auth.client.exchange({
subjectToken: inbound,
resources: ["google"],
scope: "https://www.googleapis.com/auth/calendar",
});
// downstream.access_token — real Google Calendar bearer
return { content: [{ type: "text", text: `token_type=${downstream.token_type}` }] };
},
});func readCalendarHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
inbound, _ := authplanemcp.TokenFromContext(ctx)
resp, err := adapter.Client().Exchange(ctx, authplane.ExchangeRequest{
SubjectToken: inbound,
Resources: []string{"google"},
Scope: "https://www.googleapis.com/auth/calendar",
})
if err != nil { return nil, err }
// resp.AccessToken — real Google Calendar bearer
return mcp.NewToolResultTextf("token_type=%s", resp.TokenType), nil
}Requirements checklist:
token_exchange.enabled: truein AuthPlane config (orAUTHPLANE_TOKEN_EXCHANGE_ENABLED=true).- Your MCP server’s OAuth client has
urn:ietf:params:oauth:grant-type:token-exchangein itsgrant_types. - The Broker resource’s
policy.exchange.allowed_client_idseither includes your MCP server’s client_id, or is empty (allow any consented client).
Connect flow — user grants access
Direct the user’s browser to:
GET /connect/github?return_url=http://localhost:3000/connected
The AS runs the OAuth handshake with GitHub, callback stores an encrypted row in broker_grants, then redirects the browser to return_url. First time only per (user, provider).
URL elicitation — the SDKs handle it for you
If your tool calls client.exchange(...) for a provider the user hasn’t connected yet, or with scopes exceeding their grant, AuthPlane returns error=consent_required with a consent_url pointing at /connect/{provider}. The SDK auto-translates this into MCP JSON-RPC -32042 UrlElicitationRequiredError before it reaches your handler:
{
"jsonrpc": "2.0",
"id": 1,
"error": {
"code": -32042,
"message": "URL elicitation required",
"data": {
"elicitation_id": "elic_...",
"url": "https://auth.example.com/connect/github?..."
}
}
}
The MCP client shows the URL to the user, they complete the Connect flow, then the client retries the original tools/call. No try/except in your tool code needed — see SDKs: Python / TypeScript / Go elicitation sections.
The three-bound consent model — what AuthPlane checks per vend
Every client.exchange(...) runs through five gates:
The cause sub-discriminator (consent_missing vs scope_insufficient) picks the right consent_url. The SDK translates all of these into -32042 for you.
Refresh + concurrent vends
Upstream tokens expire. AuthPlane refreshes them transparently using the stored refresh-grant when a vend request comes in for an expired token.
Concurrent vends — if two vend requests arrive at the same time and both trigger a refresh, optimistic locking serializes them. The second gets HTTP 423 Locked. Retry once on 423 — SDK clients handle this automatically.
Refresh failure (user revoked upstream access) — HTTP 400 error=consent_required, cause=consent_missing, consent_url back to /connect/{provider}. Same elicitation flow.
Listing and disconnecting
GET /connections # user's session cookie — list connected providers
DELETE /connections/{provider} # user disconnects a single provider
Admin API surfaces the same data per-user under /admin/grants.
Related
- Concepts: Token Vault — mental model + the three-bound consent
- Guides: Upstream connections — deep dive on broker providers per protocol
- Reference: Configuration → broker_providers + connect — every knob
- Topologies: Agent + brokered MCP — the topology diagram end to end
- SDKs: Python / TypeScript / Go — per-language elicitation handling