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 -32042 elicitation so your tool code stays clean. The “Token Vault” name was retired in v0.1.0-rc1; the unified Resource model with mint / broker backends is what actually implements it.

The four steps at a glance

  1. Encryptiondata_encryption block (AES master key or Vault Transit).
  2. Broker providers — register each upstream OAuth provider you’ll vend from.
  3. Connect flowconnect.* config for the browser-based user grant flow.
  4. Vend from toolsclient.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):

  1. GitHub → Settings → Developer settings → OAuth Apps → New.
  2. Set callback URL to http://localhost:9000/connect/github/callback (or your production URL).
  3. 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

server.py python
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)}

Requirements checklist:

  • token_exchange.enabled: true in AuthPlane config (or AUTHPLANE_TOKEN_EXCHANGE_ENABLED=true).
  • Your MCP server’s OAuth client has urn:ietf:params:oauth:grant-type:token-exchange in its grant_types.
  • The Broker resource’s policy.exchange.allowed_client_ids either 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.

Every client.exchange(...) runs through five gates:

GateCheckFailure
ARequested scopes are recognized for this resourceinvalid_scope
Bconsent_grants row exists for (user, agent, resource)consent_required (AS-side re-consent)
Crequested ⊆ consent_grants.scopesconsent_required (AS-side re-consent)
Dbroker_grants row exists for (user, broker_provider)consent_required (upstream /connect/{provider})
Eupstream-mapped scopes ⊆ broker_grants.scopes_grantedconsent_required (upstream re-connect)

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.