Add API-key-backed OAuth provider example and recipe docs

This commit is contained in:
Jeremiah Lowin 2026-06-02 19:33:42 -04:00
commit 44b25b0eb5
No known key found for this signature in database
6 changed files with 832 additions and 0 deletions

View file

@ -175,6 +175,7 @@
"servers/auth/oauth-proxy",
"servers/auth/oidc-proxy",
"servers/auth/full-oauth-server",
"servers/auth/api-key-oauth",
"servers/auth/multi-auth"
]
},

View file

@ -0,0 +1,170 @@
---
title: API Keys for OAuth-Only Clients
sidebarTitle: API Key OAuth
description: Let OAuth-only MCP clients authenticate with an API key your users already have.
icon: key-skeleton
---
Many services authenticate with a simple API key passed in a header, and some MCP clients support exactly that. Claude Code, for example, can attach a static header to every request:
```bash
claude mcp add -t http my-server https://example.com/mcp -H "X-API-Key: <key>"
```
Claude Desktop and ChatGPT's connectors cannot. They expose no field for a custom header, and the only authentication mechanism they implement is the MCP OAuth 2.1 handshake: when a request returns `401`, the client discovers the server's OAuth metadata and runs an authorization flow. A server whose entire auth model is an API key in a header is therefore unreachable from these clients, even though the credential the user needs is already in their possession.
This recipe closes that gap by wrapping an existing API key in an OAuth flow. The server presents a standard OAuth interface so the clients are satisfied, but the authorization page asks the user to paste their API key instead of signing in. The key is stored encrypted on the server and surfaced to tools on each request, while the client holds an ordinary OAuth token that only references it. The user supplies the key during the browser approval, exactly as they would paste it into a header—the OAuth flow transports it, with no identity provider, user database, or key lookup behind it.
<Tip>
A complete, runnable version of this recipe lives in [`examples/auth/api_key_oauth/`](https://github.com/PrefectHQ/fastmcp/tree/main/examples/auth/api_key_oauth). The code below is excerpted from it to walk through the parts that matter.
</Tip>
## How it works
The recipe builds on [`OAuthProvider`](/servers/auth/full-oauth-server), which implements the OAuth 2.1 endpoints, flows, and security requirements. The SDK's authorization handler stays in place and validates every request—response type, PKCE presence, redirect URI, scopes—then calls the provider's `authorize()`, which returns the URL to send the browser to. Rather than redirect straight back to the client, `authorize()` records the pending request and points the browser at a consent page the recipe adds. That page names the requesting client and asks for an API key; everything downstream—token exchange, PKCE verification, refresh—is handled by `OAuthProvider` and the MCP SDK.
A connection proceeds through the usual OAuth lifecycle:
1. The client receives a `401`, discovers the server's OAuth metadata, and opens a browser to `/authorize`. The SDK handler validates the request and redirects to the consent page.
2. The consent page names the client and asks for an API key. The user pastes it and submits.
3. The server binds the key to an opaque authorization code and redirects back to the client.
4. The client exchanges the code (with PKCE) at `/token`. The server stores the key encrypted, keyed by a fresh `jti`, and issues a reference-token JWT carrying only that `jti`.
5. On every subsequent request the client sends `Authorization: Bearer <jwt>`. The server validates the JWT, looks the key back up by `jti`, and tools read it from the token's claims.
The next sections follow that lifecycle: the consent page that collects the key, issuing and verifying tokens at `/token`, and reading the key inside a tool.
## Consent and the API key
Keeping the SDK's authorization handler means the provider never reimplements request validation—it only decides where to send the browser. `authorize()` records the pending request as a short-lived transaction in the encrypted store and returns the consent page URL, built from `base_url` so it stays correct under any mount path:
```python
async def authorize(self, client, params):
txn_id = secrets.token_urlsafe(32)
await self.store.put(
key=txn_id,
value={
"client_id": client.client_id,
"redirect_uri": str(params.redirect_uri),
"state": params.state or "",
"code_challenge": params.code_challenge or "",
"scopes": params.scopes or [],
},
collection="auth-txns",
ttl=900,
)
return f"{str(self.base_url).rstrip('/')}/authorize/key?txn_id={txn_id}"
```
The consent page is a `GET` route the recipe adds. It loads the transaction, names the client, and renders a single password field for the key. Submitting it `POST`s back to the same path, where the handler refuses cross-site submissions, validates the key, and binds it to an opaque authorization code—also stored encrypted. The key rides in the POST body and never appears in the redirect URL, so it cannot leak through browser history or a `Referer` header.
```python
sec_fetch_site = request.headers.get("sec-fetch-site")
if sec_fetch_site not in (None, "same-origin", "none"):
return HTMLResponse("Cross-site authorization blocked.", status_code=403)
txn = await self.store.get(key=txn_id, collection="auth-txns")
await self.store.delete(key=txn_id, collection="auth-txns")
code = f"code_{secrets.token_hex(16)}"
await self.store.put(
key=code, value={**txn, "api_key": api_key}, collection="auth-codes", ttl=300
)
location = construct_redirect_uri(txn["redirect_uri"], code=code, state=txn["state"])
return RedirectResponse(location, status_code=303)
```
<Warning>
The `code_challenge` recorded in the transaction must follow the key onto the authorization-code record, so that `load_authorization_code` can return it. The MCP SDK verifies the PKCE `code_verifier` against it at the token endpoint; if the challenge is dropped along the way, the `/token` request fails.
</Warning>
## Issuing and verifying tokens
When the client exchanges the authorization code at `/token`, the provider recovers the key bound to that code—consuming the code so it cannot be replayed—and issues a token for it:
```python
async def exchange_authorization_code(self, client, authorization_code):
rec = await self.store.get(key=authorization_code.code, collection="auth-codes")
await self.store.delete(key=authorization_code.code, collection="auth-codes")
if rec is None:
raise TokenError("invalid_grant", "Authorization code not found or used.")
return await self._issue_tokens(
api_key=rec["api_key"],
client_id=client.client_id,
scopes=authorization_code.scopes,
)
```
The token the provider issues is a *reference token*: it carries only a `jti`, while the API key itself lives in a Fernet-encrypted store keyed by that `jti`. The key is encrypted at rest and never travels on the wire. This reuses the primitives the OAuth proxy is built on, so the security-sensitive parts are not hand-rolled.
Both the token signing key and the storage encryption key derive from a single configured secret with `derive_jwt_key`, so the same secret across restarts keeps previously issued tokens valid. `JWTIssuer` mints the tokens, and the store defaults to the same encrypted file store the [OAuth proxy](/servers/auth/oauth-proxy) uses.
```python
from cryptography.fernet import Fernet
from key_value.aio.wrappers.encryption import FernetEncryptionWrapper
from fastmcp.server.auth.jwt_issuer import JWTIssuer, derive_jwt_key
signing_key = derive_jwt_key(
low_entropy_material=jwt_signing_key, salt="fastmcp-api-key-oauth-signing"
)
storage_key = derive_jwt_key(
high_entropy_material=jwt_signing_key, salt="fastmcp-storage-encryption-key"
)
issuer = JWTIssuer(issuer=base_url, audience=resource_url, signing_key=signing_key)
store = FernetEncryptionWrapper(key_value=file_store, fernet=Fernet(key=storage_key))
```
With those in place, issuing a token is storing the key under a fresh `jti` and minting a JWT that references it:
```python
access_jti = secrets.token_urlsafe(16)
await store.put(key=access_jti, value={"api_key": api_key}, collection="api-keys")
access_token = issuer.issue_access_token(client_id=client_id, scopes=scopes, jti=access_jti)
```
Verification runs the reverse: validate the JWT signature and claims, then look the key back up by `jti`. The decrypted key is surfaced on `AccessToken.claims` so tools can read it, where it lives only in memory for the duration of the request.
```python
async def load_access_token(self, token: str) -> AccessToken | None:
payload = self.jwt_issuer.verify_token(token)
record = await self.store.get(key=payload["jti"], collection="api-keys")
if record is None:
return None
return AccessToken(
token=token,
client_id=payload["client_id"],
scopes=payload["scope"].split(),
claims={API_KEY_CLAIM: record["api_key"]},
)
```
## Reading the key
Inside a tool, the key arrives through the request's access token. The `get_access_token` dependency returns the current `AccessToken`, and the key is waiting in its claims, ready to construct whatever client the tool needs:
```python
from fastmcp.server.dependencies import get_access_token
@mcp.tool
def query(sql: str) -> str:
token = get_access_token()
api_key = token.claims[API_KEY_CLAIM]
client = my_service.Client(api_key=api_key)
return client.run(sql)
```
## Connecting a client
Adding the server to Claude Desktop or ChatGPT as a custom connector points the client at the `/mcp` URL. The first connection opens a browser to the API-key page; from then on the connector behaves like any other OAuth connector, and the user does not see the form again until their token expires.
Clients that support headers continue to work unchanged. A server that also accepts the key as a bearer token or custom header—through a [`TokenVerifier`](/servers/auth/token-verification) or middleware—can point both paths at the same verification logic, so Claude Code and the desktop apps share a single code path.
## Production considerations
The example is a reference rather than a drop-in, and a few details deserve attention before it goes to production:
- **Load `jwt_signing_key` from a secret store.** Both the token signing key and the storage encryption key derive from it, so the same secret across restarts keeps previously issued tokens valid. A throwaway value invalidates every outstanding token on restart.
- **The default store is single-host.** It defaults to the on-disk Fernet-encrypted file store the proxy uses. A multi-worker or multi-replica deployment needs a shared `client_storage` (Redis, a database) so a token issued by one worker resolves on another.
- **Registered clients live in process memory.** They are cheaply re-created through dynamic client registration, but a production server may prefer to persist them; transactions, codes, and keys already live in the shared store.
- **The consent page is deliberately minimal.** It names the client and blocks cross-site submission, which covers the basic phishing case, but it does not implement the full consent machinery—cookie-bound "remember" decisions, CSP tuning—that [`OAuthProxy`](/servers/auth/oauth-proxy) provides. Harden it before exposing the server to untrusted users.
- **Validate the key at the authorization step.** Rejecting a bad key before a token is minted produces a clearer failure than letting it surface on the first tool call.

View file

@ -0,0 +1,88 @@
# API-Key-Backed OAuth Example
Make OAuth-only MCP clients work with a service that authenticates by API key.
## The problem
Claude Code can send a static header to a remote MCP server:
```bash
claude mcp add -t http my-server https://example.com/mcp -H "X-API-Key: <key>"
```
Claude Desktop and ChatGPT's connectors cannot. They expose no field for a
custom header — the only authentication mechanism they implement is the MCP
OAuth 2.1 handshake. A service whose entire auth model is "send your API key in
a header" therefore cannot reach those clients at all, even though the
credential the user needs (their API key) is sitting right there.
## The approach
This `APIKeyOAuthProvider` speaks full OAuth so the clients are satisfied, but
replaces the usual username/password login with a consent page that names the
requesting client and asks the user to **paste the API key they already have**.
The OAuth dance is purely a transport for the key — no identity provider, no user
database, no key lookup.
It keeps the SDK's authorization handler (so request validation and PKCE are
unchanged) and reuses the same primitives FastMCP's OAuth proxy is built on:
`derive_jwt_key` turns a configured secret into the token signing key and a
Fernet storage-encryption key, `JWTIssuer` issues *reference tokens* that carry
only a `jti`, and a Fernet-encrypted store holds the transaction, the
authorization code, and the API key. The key is encrypted at rest and never
travels on the wire; tools read it back with `get_access_token()`.
```python
@mcp.tool
def whoami() -> str:
token = get_access_token()
api_key = token.claims[API_KEY_CLAIM]
# client = my_service.Client(api_key=api_key)
return f"Authenticated with API key: {api_key[:4]}…"
```
## Run it
```bash
python server.py
```
In another terminal:
```bash
python client.py
```
A browser opens to the "paste your API key" page. The demo server accepts any
non-empty key; enter anything and the connection completes. `whoami` then
echoes the key the server recovered from your token.
To wire it into a real client, point Claude Desktop / ChatGPT at
`http://127.0.0.1:8000/mcp` as a custom connector.
## Production notes
This is a reference, not a drop-in. Before shipping:
- **The API key is encrypted at rest and never on the wire.** The access token
is a reference token carrying only a `jti`; the key lives in the Fernet-
encrypted store keyed by that `jti`. It also never travels in a URL — it is
submitted in the form POST body and bound to an opaque authorization code.
- **Load `jwt_signing_key` from your secret store.** Both the token signing key
and the storage encryption key derive from it, so the same secret across
restarts keeps previously issued tokens valid.
- **The default store is single-host.** It defaults to an on-disk Fernet-
encrypted file store. For a multi-worker or multi-replica deployment, pass a
shared `client_storage` (e.g. Redis-backed) so a token issued by one worker
resolves on another.
- **Registered clients live in process memory.** They are cheaply re-created via
dynamic client registration; a production server may prefer to persist them.
Transactions, authorization codes, and keys already live in the shared
encrypted store.
- **The consent page is deliberately minimal.** It names the client and blocks
cross-site form submission, which covers the basic phishing case, but it does
not implement the full consent machinery (cookie-bound "remember" decisions,
CSP tuning) that `OAuthProxy` provides. Harden it before exposing the server to
untrusted users.
- **Validate the key at the authorize step** by passing `validate_api_key=` so a
bogus key is rejected before a token is minted rather than failing later.

View file

@ -0,0 +1,28 @@
"""Connect to the API-key-backed OAuth server.
Running this triggers the OAuth flow: a browser window opens to the server's
"paste your API key" page. Enter any non-empty key (the demo server accepts
anything) and the connection completes. The `whoami` tool then echoes the key
the server recovered from your token.
To run (with server.py already running):
python client.py
"""
import asyncio
from fastmcp.client import Client
SERVER_URL = "http://127.0.0.1:8000/mcp"
async def main():
async with Client(SERVER_URL, auth="oauth") as client:
assert await client.ping()
print("✅ Authenticated")
result = await client.call_tool("whoami", {})
print(f"🔑 {result.data}")
if __name__ == "__main__":
asyncio.run(main())

View file

@ -0,0 +1,494 @@
"""An OAuth provider that authenticates users by their existing API key.
OAuth-only MCP clients (Claude Desktop, ChatGPT's connectors) cannot send a
custom header to a remote server the way Claude Code can with
`-H "X-API-Key: ..."`. When they hit a server that requires auth, the only
mechanism they implement is the MCP OAuth 2.1 handshake. That leaves services
with a perfectly good API-key auth model unable to reach those clients.
This provider bridges the gap. It speaks full OAuth so the clients are happy,
but the "login" step is not a username/password form it is a consent page that
names the requesting client and asks the user to paste the API key they already
have. From there the provider reuses the same primitives FastMCP's OAuth proxy
is built on:
- The SDK's `AuthorizationHandler` stays on `/authorize` and performs all the
standard request validation (response type, PKCE presence, redirect URI,
scopes). `authorize()` then redirects the browser to our consent page, exactly
as the proxy redirects to its `/consent` page.
- `derive_jwt_key` turns a configured secret into the HS256 signing key (and a
Fernet storage-encryption key), so nothing depends on an ephemeral key that a
restart would invalidate.
- `JWTIssuer` issues FastMCP's own tokens as *reference tokens*: each token
carries only a `jti`, never the API key itself.
- A Fernet-encrypted key-value store holds the transient transaction, the
authorization code, and the API key each keyed and TTL-bound, encrypted at
rest. The key never travels on the wire; `load_access_token` validates the JWT
and looks the key back up.
Tools read the key through `get_access_token().claims`.
Deployment notes:
- The encrypted store defaults to an on-disk file store (the same default the
OAuth proxy uses). It is single-host. For a multi-worker or multi-replica
deployment, pass a shared `client_storage` (e.g. a Redis-backed store) so a
token issued by one worker resolves on another.
- Registered clients are kept in process memory; they are cheaply re-created via
dynamic client registration. A production server may prefer to persist them.
"""
from __future__ import annotations
import hashlib
import html
import secrets
import time
from collections.abc import Callable
import anyio
from cryptography.fernet import Fernet
from joserfc.errors import JoseError
from key_value.aio.protocols import AsyncKeyValue
from key_value.aio.stores.filetree import (
FileTreeStore,
FileTreeV1CollectionSanitizationStrategy,
FileTreeV1KeySanitizationStrategy,
)
from key_value.aio.wrappers.encryption import FernetEncryptionWrapper
from mcp.server.auth.provider import (
AuthorizationCode,
AuthorizationParams,
RefreshToken,
TokenError,
construct_redirect_uri,
)
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
from pydantic import AnyHttpUrl
from starlette.requests import Request
from starlette.responses import HTMLResponse, RedirectResponse, Response
from starlette.routing import Route
from fastmcp import settings
from fastmcp.server.auth.auth import (
AccessToken,
ClientRegistrationOptions,
OAuthProvider,
)
from fastmcp.server.auth.jwt_issuer import JWTIssuer, derive_jwt_key
# The JWT claim the API key is surfaced under, and the store collections that
# hold (transiently) each stage of the flow and (longer) the key itself.
API_KEY_CLAIM = "api_key"
KEY_COLLECTION = "api-keys"
TXN_COLLECTION = "auth-txns"
CODE_COLLECTION = "auth-codes"
# The path of the consent/key-entry page, relative to the server's base URL.
KEY_ENTRY_PATH = "/authorize/key"
TXN_EXPIRY_SECONDS = 15 * 60
AUTH_CODE_EXPIRY_SECONDS = 5 * 60
DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS = 60 * 60
DEFAULT_REFRESH_TOKEN_EXPIRY_SECONDS = 60 * 60 * 24 * 30 # 30 days
class APIKeyOAuthProvider(OAuthProvider):
"""OAuth provider whose consent page collects an API key.
The provider runs the standard OAuth authorization-code flow the SDK's
handler validates each authorize request but the user-facing step is a
consent page that names the client and asks for an API key instead of a
password. The key is stored encrypted and bound to the issued token's `jti`;
tools recover it from the request's access token claims.
Args:
base_url: The public URL of this FastMCP server (include any mount path).
Used as the JWT issuer, the OAuth issuer, and the base for the
consent page URL.
jwt_signing_key: A secret string. The HS256 signing key and the storage
encryption key are both derived from it, so the same secret across
restarts keeps previously issued tokens valid.
validate_api_key: Optional callable that receives the pasted key and
returns True if it is valid. Use it to reject bad keys at the consent
step instead of minting a token that fails later. Defaults to
accepting any non-empty key.
client_storage: Optional key-value store for the encrypted transaction,
code, and key records. Defaults to an on-disk Fernet-encrypted file
store. Pass a shared store for multi-worker deployments.
token_expiry_seconds: Lifetime of issued access tokens.
refresh_expiry_seconds: Lifetime of issued refresh tokens.
required_scopes: Scopes required on every request.
"""
def __init__(
self,
*,
base_url: AnyHttpUrl | str,
jwt_signing_key: str,
validate_api_key: Callable[[str], bool] | None = None,
client_storage: AsyncKeyValue | None = None,
token_expiry_seconds: int = DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS,
refresh_expiry_seconds: int = DEFAULT_REFRESH_TOKEN_EXPIRY_SECONDS,
required_scopes: list[str] | None = None,
):
super().__init__(
base_url=base_url,
client_registration_options=ClientRegistrationOptions(enabled=True),
required_scopes=required_scopes,
)
# Derive the HS256 signing key from the secret (PBKDF2). The JWTIssuer is
# created in set_mcp_path() once the audience (resource URL) is known.
self._signing_key = derive_jwt_key(
low_entropy_material=jwt_signing_key,
salt="fastmcp-api-key-oauth-signing",
)
self._jwt_issuer: JWTIssuer | None = None
# Encrypted store, defaulting to the same Fernet-wrapped file store the
# OAuth proxy uses, with the encryption key derived from the same secret.
if client_storage is None:
storage_key = derive_jwt_key(
high_entropy_material=jwt_signing_key,
salt="fastmcp-storage-encryption-key",
)
fingerprint = hashlib.sha256(storage_key).hexdigest()[:12]
storage_dir = settings.home / "api-key-oauth" / fingerprint
storage_dir.mkdir(parents=True, exist_ok=True)
file_store = FileTreeStore(
data_directory=storage_dir,
key_sanitization_strategy=FileTreeV1KeySanitizationStrategy(
storage_dir
),
collection_sanitization_strategy=FileTreeV1CollectionSanitizationStrategy(
storage_dir
),
)
client_storage = FernetEncryptionWrapper(
key_value=file_store,
fernet=Fernet(key=storage_key),
raise_on_decryption_error=False,
)
self._store: AsyncKeyValue = client_storage
self._validate_api_key = validate_api_key or (lambda key: bool(key))
self._token_expiry = token_expiry_seconds
self._refresh_expiry = refresh_expiry_seconds
self._clients: dict[str, OAuthClientInformationFull] = {}
# Per-grant locks so concurrent exchanges of the same code or refresh
# token cannot each consume it and mint a fresh pair.
self._grant_locks: dict[str, anyio.Lock] = {}
def set_mcp_path(self, mcp_path: str | None) -> None:
# Bind the JWT audience to the resource URL, mirroring OAuthProxy.
super().set_mcp_path(mcp_path)
self._jwt_issuer = JWTIssuer(
issuer=str(self.base_url),
audience=str(self._resource_url),
signing_key=self._signing_key,
)
@property
def jwt_issuer(self) -> JWTIssuer:
if self._jwt_issuer is None:
raise RuntimeError(
"JWT issuer not initialized; ensure get_routes() has run."
)
return self._jwt_issuer
@property
def _key_entry_url(self) -> str:
return f"{str(self.base_url).rstrip('/')}{KEY_ENTRY_PATH}"
# -- Client registration -------------------------------------------------
async def get_client(self, client_id: str) -> OAuthClientInformationFull | None:
return self._clients.get(client_id)
async def register_client(self, client_info: OAuthClientInformationFull) -> None:
if client_info.client_id is None:
raise ValueError("client_id is required for client registration")
self._clients[client_info.client_id] = client_info
# -- Authorization -------------------------------------------------------
async def authorize(
self, client: OAuthClientInformationFull, params: AuthorizationParams
) -> str:
# The SDK's AuthorizationHandler has already validated the request
# (response type, PKCE presence, redirect URI, scopes). Persist the
# transaction and send the browser to our consent/key-entry page.
txn_id = secrets.token_urlsafe(32)
await self._store.put(
key=txn_id,
value={
"client_id": client.client_id,
"redirect_uri": str(params.redirect_uri),
"redirect_uri_provided_explicitly": params.redirect_uri_provided_explicitly,
"state": params.state or "",
"code_challenge": params.code_challenge or "",
"scopes": params.scopes or [],
},
collection=TXN_COLLECTION,
ttl=TXN_EXPIRY_SECONDS,
)
return f"{self._key_entry_url}?txn_id={txn_id}"
async def _render_form(self, request: Request) -> Response:
"""GET the consent page — name the client and ask for the API key."""
txn_id = request.query_params.get("txn_id", "")
txn = await self._store.get(key=txn_id, collection=TXN_COLLECTION)
if txn is None:
return HTMLResponse("Authorization request expired.", status_code=400)
client = await self.get_client(txn["client_id"])
client_name = (client.client_name if client else None) or txn["client_id"]
page = f"""<!doctype html>
<html><head><meta charset="utf-8"><title>Authorize</title></head>
<body style="font-family: system-ui; max-width: 28rem; margin: 4rem auto;">
<h2>Authorize {html.escape(client_name)}</h2>
<p><strong>{html.escape(client_name)}</strong> is requesting access. Paste your
API key to authorize it. Only do this if you started this connection.</p>
<form method="post" action="{html.escape(self._key_entry_url, quote=True)}">
<input type="hidden" name="txn_id" value="{html.escape(txn_id, quote=True)}">
<input type="password" name="api_key" placeholder="API key"
autocomplete="off" required
style="width: 100%; padding: 0.5rem; font-size: 1rem;">
<button type="submit" style="margin-top: 1rem; padding: 0.5rem 1rem;">
Authorize
</button>
</form>
</body></html>"""
return HTMLResponse(page)
async def _handle_submit(self, request: Request) -> Response:
"""POST the consent page — validate the key and issue an auth code."""
# Block cross-site form submission; the consent action must originate
# from our own page (or a top-level navigation).
sec_fetch_site = request.headers.get("sec-fetch-site")
if sec_fetch_site not in (None, "same-origin", "none"):
return HTMLResponse("Cross-site authorization blocked.", status_code=403)
form = await request.form()
txn_id = str(form.get("txn_id", ""))
api_key = str(form.get("api_key", ""))
txn = await self._store.get(key=txn_id, collection=TXN_COLLECTION)
if txn is None:
return HTMLResponse("Authorization request expired.", status_code=400)
if not self._validate_api_key(api_key):
return RedirectResponse(
f"{self._key_entry_url}?txn_id={txn_id}", status_code=303
)
# Consume the transaction and bind the key to a fresh, opaque code.
await self._store.delete(key=txn_id, collection=TXN_COLLECTION)
code = f"code_{secrets.token_hex(16)}"
await self._store.put(
key=code,
value={
**txn,
"api_key": api_key,
"expires_at": time.time() + AUTH_CODE_EXPIRY_SECONDS,
},
collection=CODE_COLLECTION,
ttl=AUTH_CODE_EXPIRY_SECONDS,
)
location = construct_redirect_uri(
txn["redirect_uri"], code=code, state=txn["state"]
)
return RedirectResponse(location, status_code=303)
async def load_authorization_code(
self, client: OAuthClientInformationFull, authorization_code: str
) -> AuthorizationCode | None:
rec = await self._store.get(key=authorization_code, collection=CODE_COLLECTION)
if rec is None or rec["client_id"] != client.client_id:
return None
return AuthorizationCode(
code=authorization_code,
client_id=rec["client_id"],
redirect_uri=AnyHttpUrl(rec["redirect_uri"]),
redirect_uri_provided_explicitly=rec["redirect_uri_provided_explicitly"],
scopes=rec["scopes"],
expires_at=rec["expires_at"],
code_challenge=rec["code_challenge"],
)
# -- Token issuance ------------------------------------------------------
async def _issue_tokens(
self, *, api_key: str, client_id: str, scopes: list[str]
) -> OAuthToken:
"""Mint a reference-token pair and store the key encrypted under each jti."""
access_jti = secrets.token_urlsafe(16)
refresh_jti = secrets.token_urlsafe(16)
record = {"api_key": api_key, "client_id": client_id}
await self._store.put(
key=access_jti,
value=record,
collection=KEY_COLLECTION,
ttl=self._token_expiry,
)
await self._store.put(
key=refresh_jti,
value=record,
collection=KEY_COLLECTION,
ttl=self._refresh_expiry,
)
access_token = self.jwt_issuer.issue_access_token(
client_id=client_id,
scopes=scopes,
jti=access_jti,
expires_in=self._token_expiry,
)
refresh_token = self.jwt_issuer.issue_refresh_token(
client_id=client_id,
scopes=scopes,
jti=refresh_jti,
expires_in=self._refresh_expiry,
)
return OAuthToken(
access_token=access_token,
token_type="Bearer",
expires_in=self._token_expiry,
refresh_token=refresh_token,
scope=" ".join(scopes),
)
async def exchange_authorization_code(
self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode
) -> OAuthToken:
code = authorization_code.code
lock_key = f"code:{code}"
# Serialize concurrent exchanges of the same code so only the first
# consumes it; the rest find it gone and are rejected.
try:
async with self._grant_lock(lock_key):
rec = await self._store.get(key=code, collection=CODE_COLLECTION)
# Consume the code so it cannot be replayed.
await self._store.delete(key=code, collection=CODE_COLLECTION)
if rec is None:
raise TokenError(
"invalid_grant", "Authorization code not found or used."
)
return await self._issue_tokens(
api_key=rec["api_key"],
client_id=client.client_id or "",
scopes=authorization_code.scopes,
)
finally:
self._grant_locks.pop(lock_key, None)
def _grant_lock(self, key: str) -> anyio.Lock:
lock = self._grant_locks.get(key)
if lock is None:
lock = anyio.Lock()
self._grant_locks[key] = lock
return lock
async def load_refresh_token(
self, client: OAuthClientInformationFull, refresh_token: str
) -> RefreshToken | None:
try:
payload = self.jwt_issuer.verify_token(
refresh_token, expected_token_use="refresh"
)
except JoseError:
return None
if payload.get("client_id") != client.client_id:
return None
if await self._store.get(key=payload["jti"], collection=KEY_COLLECTION) is None:
return None
scope = payload.get("scope", "")
return RefreshToken(
token=refresh_token,
client_id=client.client_id or "",
scopes=scope.split() if scope else [],
)
async def exchange_refresh_token(
self,
client: OAuthClientInformationFull,
refresh_token: RefreshToken,
scopes: list[str],
) -> OAuthToken:
try:
payload = self.jwt_issuer.verify_token(
refresh_token.token, expected_token_use="refresh"
)
except JoseError as exc:
raise TokenError("invalid_grant", "Invalid refresh token.") from exc
jti = payload["jti"]
if not set(scopes).issubset(set(refresh_token.scopes)):
raise TokenError("invalid_scope", "Requested scopes exceed grant.")
granted = scopes or refresh_token.scopes
lock_key = f"refresh:{jti}"
# Serialize refreshes of the same token so concurrent calls cannot each
# mint a fresh pair from one refresh token.
try:
async with self._grant_lock(lock_key):
record = await self._store.get(key=jti, collection=KEY_COLLECTION)
if record is None:
raise TokenError("invalid_grant", "Refresh token not found.")
# Rotate: invalidate this refresh token's stored key.
await self._store.delete(key=jti, collection=KEY_COLLECTION)
return await self._issue_tokens(
api_key=record["api_key"],
client_id=record["client_id"],
scopes=granted,
)
finally:
self._grant_locks.pop(lock_key, None)
# -- Verification & revocation -------------------------------------------
async def load_access_token(self, token: str) -> AccessToken | None:
try:
payload = self.jwt_issuer.verify_token(token)
except JoseError:
return None
record = await self._store.get(key=payload["jti"], collection=KEY_COLLECTION)
if record is None:
return None
scope = payload.get("scope", "")
# Surface the decrypted key on the token's claims so tools can read it
# via get_access_token(). It lives only in memory here, never on the wire.
return AccessToken(
token=token,
client_id=payload.get("client_id", ""),
scopes=scope.split() if scope else [],
expires_at=payload.get("exp"),
claims={API_KEY_CLAIM: record["api_key"]},
)
async def revoke_token(self, token: AccessToken | RefreshToken) -> None:
try:
payload = self.jwt_issuer.verify_token(
token.token,
expected_token_use="refresh"
if isinstance(token, RefreshToken)
else "access",
)
except JoseError:
return
await self._store.delete(key=payload["jti"], collection=KEY_COLLECTION)
# -- Routes --------------------------------------------------------------
def get_routes(self, mcp_path: str | None = None) -> list[Route]:
# Keep the SDK's /authorize route (it validates the request); add the
# consent/key-entry page authorize() redirects to.
routes = super().get_routes(mcp_path)
routes.append(Route(KEY_ENTRY_PATH, self._render_form, methods=["GET"]))
routes.append(Route(KEY_ENTRY_PATH, self._handle_submit, methods=["POST"]))
return routes

View file

@ -0,0 +1,51 @@
"""A FastMCP server protected by API-key-backed OAuth.
OAuth-only clients (Claude Desktop, ChatGPT) connect, get redirected to a
"paste your API key" page, and from then on the server can read each user's key
inside tools without those clients ever needing to send a custom header.
To run:
python server.py
Then point an OAuth-capable MCP client at http://127.0.0.1:8000/mcp, or use the
companion client.py.
"""
from provider import API_KEY_CLAIM, APIKeyOAuthProvider
from fastmcp import FastMCP
from fastmcp.server.dependencies import get_access_token
SERVER_URL = "http://127.0.0.1:8000"
def validate_api_key(key: str) -> bool:
# Replace with a real check against your service. Returning True here means
# any non-empty key is accepted at the authorize step.
return bool(key)
auth = APIKeyOAuthProvider(
base_url=SERVER_URL,
# Derives the token signing key and the storage encryption key. Load this
# from your secret store in production; the same secret keeps previously
# issued tokens valid across restarts.
jwt_signing_key="change-me-to-a-real-secret",
validate_api_key=validate_api_key,
)
mcp = FastMCP("API-Key OAuth Demo", auth=auth)
@mcp.tool
def whoami() -> str:
"""Return the API key the current user authenticated with."""
token = get_access_token()
api_key = token.claims[API_KEY_CLAIM]
# In a real server you would instantiate your client here, e.g.
# client = my_service.Client(api_key=api_key)
return f"Authenticated with API key: {api_key[:4]}"
if __name__ == "__main__":
mcp.run(transport="http", host="127.0.0.1", port=8000)