From 44b25b0eb5a683e089e787b346b033d0ddf8f29f Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 2 Jun 2026 19:33:42 -0400 Subject: [PATCH 1/3] Add API-key-backed OAuth provider example and recipe docs --- docs/docs.json | 1 + docs/servers/auth/api-key-oauth.mdx | 170 ++++++++ examples/auth/api_key_oauth/README.md | 88 +++++ examples/auth/api_key_oauth/client.py | 28 ++ examples/auth/api_key_oauth/provider.py | 494 ++++++++++++++++++++++++ examples/auth/api_key_oauth/server.py | 51 +++ 6 files changed, 832 insertions(+) create mode 100644 docs/servers/auth/api-key-oauth.mdx create mode 100644 examples/auth/api_key_oauth/README.md create mode 100644 examples/auth/api_key_oauth/client.py create mode 100644 examples/auth/api_key_oauth/provider.py create mode 100644 examples/auth/api_key_oauth/server.py diff --git a/docs/docs.json b/docs/docs.json index 86452fe59..0199273f7 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -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" ] }, diff --git a/docs/servers/auth/api-key-oauth.mdx b/docs/servers/auth/api-key-oauth.mdx new file mode 100644 index 000000000..24739f183 --- /dev/null +++ b/docs/servers/auth/api-key-oauth.mdx @@ -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: " +``` + +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. + + +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. + + +## 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 `. 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) +``` + + +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. + + +## 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. diff --git a/examples/auth/api_key_oauth/README.md b/examples/auth/api_key_oauth/README.md new file mode 100644 index 000000000..ba22aa64b --- /dev/null +++ b/examples/auth/api_key_oauth/README.md @@ -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: " +``` + +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. diff --git a/examples/auth/api_key_oauth/client.py b/examples/auth/api_key_oauth/client.py new file mode 100644 index 000000000..ce05172a4 --- /dev/null +++ b/examples/auth/api_key_oauth/client.py @@ -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()) diff --git a/examples/auth/api_key_oauth/provider.py b/examples/auth/api_key_oauth/provider.py new file mode 100644 index 000000000..5a42b613a --- /dev/null +++ b/examples/auth/api_key_oauth/provider.py @@ -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""" +Authorize + +

Authorize {html.escape(client_name)}

+

{html.escape(client_name)} is requesting access. Paste your + API key to authorize it. Only do this if you started this connection.

+
+ + + +
+""" + 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 diff --git a/examples/auth/api_key_oauth/server.py b/examples/auth/api_key_oauth/server.py new file mode 100644 index 000000000..585269206 --- /dev/null +++ b/examples/auth/api_key_oauth/server.py @@ -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) From f38a5ae5c655b17533ce87007ca43b91689d5e57 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 2 Jun 2026 19:42:53 -0400 Subject: [PATCH 2/3] Show backend key validation and key usage in API-key OAuth example --- docs/servers/auth/api-key-oauth.mdx | 19 ++++++-- examples/auth/api_key_oauth/README.md | 31 +++++++++---- examples/auth/api_key_oauth/client.py | 8 ++-- examples/auth/api_key_oauth/provider.py | 19 +++++--- examples/auth/api_key_oauth/server.py | 58 +++++++++++++++++++------ 5 files changed, 98 insertions(+), 37 deletions(-) diff --git a/docs/servers/auth/api-key-oauth.mdx b/docs/servers/auth/api-key-oauth.mdx index 24739f183..2097a0f69 100644 --- a/docs/servers/auth/api-key-oauth.mdx +++ b/docs/servers/auth/api-key-oauth.mdx @@ -76,6 +76,17 @@ return RedirectResponse(location, status_code=303) 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. +This is also where you confirm the key is real. The provider takes a `validate_api_key` hook that runs before the code is issued, so a bad key fails on the consent page rather than on the first tool call. The hook may be async, which is what you want when verification is an HTTP call to your own backend: + +```python +async def validate_api_key(key: str) -> bool: + async with httpx.AsyncClient() as client: + response = await client.get( + "https://api.example.com/me", headers={"Authorization": f"Bearer {key}"} + ) + return response.is_success +``` + ## 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: @@ -139,15 +150,15 @@ async def load_access_token(self, token: str) -> AccessToken | None: ## 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: +Inside a tool, the key arrives through the request's access token. The `CurrentAccessToken` dependency injects the current `AccessToken` (and raises if the request is unauthenticated), 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 +from fastmcp.server.auth import AccessToken +from fastmcp.server.dependencies import CurrentAccessToken @mcp.tool -def query(sql: str) -> str: - token = get_access_token() +def query(sql: str, token: AccessToken = CurrentAccessToken()) -> str: api_key = token.claims[API_KEY_CLAIM] client = my_service.Client(api_key=api_key) return client.run(sql) diff --git a/examples/auth/api_key_oauth/README.md b/examples/auth/api_key_oauth/README.md index ba22aa64b..3c827e04a 100644 --- a/examples/auth/api_key_oauth/README.md +++ b/examples/auth/api_key_oauth/README.md @@ -30,15 +30,29 @@ unchanged) and reuses the same primitives FastMCP's OAuth proxy is built on: 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()`. +travels on the wire; tools read it back from the access token claims. + +Two integration points are yours to fill in. First, verify the pasted key +against your backend before a token is issued — the hook may be async, so it can +make an HTTP call: + +```python +async def validate_api_key(key: str) -> bool: + async with httpx.AsyncClient() as client: + response = await client.get( + API_VERIFY_URL, headers={"Authorization": f"Bearer {key}"} + ) + return response.is_success +``` + +Second, read the key inside a tool and use it to construct your client: ```python @mcp.tool -def whoami() -> str: - token = get_access_token() +async def list_files(token: AccessToken = CurrentAccessToken()) -> list[str]: api_key = token.claims[API_KEY_CLAIM] - # client = my_service.Client(api_key=api_key) - return f"Authenticated with API key: {api_key[:4]}…" + client = my_service.Client(api_key=api_key) + return await client.list_files() ``` ## Run it @@ -53,9 +67,10 @@ In another terminal: 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. +A browser opens to the consent page. The demo server accepts any non-empty key +(set `API_VERIFY_URL` to validate against a real backend); enter anything and the +connection completes. `list_files` then runs with 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. diff --git a/examples/auth/api_key_oauth/client.py b/examples/auth/api_key_oauth/client.py index ce05172a4..6a9028ab1 100644 --- a/examples/auth/api_key_oauth/client.py +++ b/examples/auth/api_key_oauth/client.py @@ -1,9 +1,9 @@ """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. +consent page. Enter any non-empty key (the demo server accepts anything) and the +connection completes. The `list_files` tool then runs with the key the server +recovered from your token. To run (with server.py already running): python client.py @@ -20,7 +20,7 @@ async def main(): async with Client(SERVER_URL, auth="oauth") as client: assert await client.ping() print("✅ Authenticated") - result = await client.call_tool("whoami", {}) + result = await client.call_tool("list_files", {}) print(f"🔑 {result.data}") diff --git a/examples/auth/api_key_oauth/provider.py b/examples/auth/api_key_oauth/provider.py index 5a42b613a..20152f050 100644 --- a/examples/auth/api_key_oauth/provider.py +++ b/examples/auth/api_key_oauth/provider.py @@ -26,7 +26,7 @@ is built on: 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`. +Tools read the key from the access token claims (e.g. via `CurrentAccessToken`). Deployment notes: @@ -42,9 +42,10 @@ from __future__ import annotations import hashlib import html +import inspect import secrets import time -from collections.abc import Callable +from collections.abc import Awaitable, Callable import anyio from cryptography.fernet import Fernet @@ -110,9 +111,10 @@ class APIKeyOAuthProvider(OAuthProvider): 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. + returns True if it is valid. May be sync or async — return a + coroutine to verify the key against your backend over HTTP. 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. @@ -126,7 +128,7 @@ class APIKeyOAuthProvider(OAuthProvider): *, base_url: AnyHttpUrl | str, jwt_signing_key: str, - validate_api_key: Callable[[str], bool] | None = None, + validate_api_key: Callable[[str], bool | Awaitable[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, @@ -279,7 +281,10 @@ class APIKeyOAuthProvider(OAuthProvider): if txn is None: return HTMLResponse("Authorization request expired.", status_code=400) - if not self._validate_api_key(api_key): + result = self._validate_api_key(api_key) + if inspect.isawaitable(result): + result = await result + if not result: return RedirectResponse( f"{self._key_entry_url}?txn_id={txn_id}", status_code=303 ) diff --git a/examples/auth/api_key_oauth/server.py b/examples/auth/api_key_oauth/server.py index 585269206..8fb94c7cb 100644 --- a/examples/auth/api_key_oauth/server.py +++ b/examples/auth/api_key_oauth/server.py @@ -1,28 +1,57 @@ """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. +consent page that asks for an API key, and from then on the server can read each +user's key inside tools — without those clients ever needing to send a custom +header. + +Two integration points are yours to fill in: + +- `validate_api_key` verifies the pasted key against your backend before a token + is minted. It may be async, so it can make an HTTP call. +- Inside a tool, the access token's claims carry the key the user authenticated + with, ready to construct whatever client you need. 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. +Set API_VERIFY_URL to point validation at a real backend; without it the demo +accepts any non-empty key. Then point an OAuth-capable MCP client at +http://127.0.0.1:8000/mcp, or use the companion client.py. """ +import os + +import httpx from provider import API_KEY_CLAIM, APIKeyOAuthProvider from fastmcp import FastMCP -from fastmcp.server.dependencies import get_access_token +from fastmcp.server.auth import AccessToken +from fastmcp.server.dependencies import CurrentAccessToken SERVER_URL = "http://127.0.0.1:8000" +# The endpoint that confirms a key is valid. Yours might be a "get current user" +# route that returns 200 for a good key and 401 for a bad one. +API_VERIFY_URL = os.environ.get("API_VERIFY_URL") -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) + +async def validate_api_key(key: str) -> bool: + """Confirm the pasted key is real before issuing a token. + + Rejecting a bad key here produces a clear failure on the consent page + instead of a token that breaks on the first tool call. The demo accepts any + non-empty key when no backend is configured. + """ + if not key: + return False + if API_VERIFY_URL is None: + return True + async with httpx.AsyncClient() as client: + response = await client.get( + API_VERIFY_URL, headers={"Authorization": f"Bearer {key}"} + ) + return response.is_success auth = APIKeyOAuthProvider( @@ -38,13 +67,14 @@ 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() +async def list_files(token: AccessToken = CurrentAccessToken()) -> list[str]: + """List the caller's files, using the key they authenticated with.""" api_key = token.claims[API_KEY_CLAIM] - # In a real server you would instantiate your client here, e.g. + # Construct your own client from the key and call your service. Here we just + # echo a masked key so the demo runs without a backend. # client = my_service.Client(api_key=api_key) - return f"Authenticated with API key: {api_key[:4]}…" + # return await client.list_files() + return [f"(demo) authenticated with {api_key[:4]}…"] if __name__ == "__main__": From 11300950fbe387232a43e83e83ae180b1d26e57c Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 2 Jun 2026 19:44:47 -0400 Subject: [PATCH 3/3] Clarify the API key travels in the consent POST, not 'never on the wire' --- docs/servers/auth/api-key-oauth.mdx | 2 +- examples/auth/api_key_oauth/README.md | 12 +++++++----- examples/auth/api_key_oauth/provider.py | 7 ++++--- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/docs/servers/auth/api-key-oauth.mdx b/docs/servers/auth/api-key-oauth.mdx index 2097a0f69..bfa791e9d 100644 --- a/docs/servers/auth/api-key-oauth.mdx +++ b/docs/servers/auth/api-key-oauth.mdx @@ -104,7 +104,7 @@ async def exchange_authorization_code(self, client, authorization_code): ) ``` -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. +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 appears in a URL or in the token itself—the user submits it once in the consent POST, so the server should be served over HTTPS. 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. diff --git a/examples/auth/api_key_oauth/README.md b/examples/auth/api_key_oauth/README.md index 3c827e04a..631456fe7 100644 --- a/examples/auth/api_key_oauth/README.md +++ b/examples/auth/api_key_oauth/README.md @@ -30,7 +30,8 @@ unchanged) and reuses the same primitives FastMCP's OAuth proxy is built on: 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 from the access token claims. +appears in a URL or in the issued token; tools read it back from the access +token claims. Two integration points are yours to fill in. First, verify the pasted key against your backend before a token is issued — the hook may be async, so it can @@ -79,10 +80,11 @@ To wire it into a real client, point Claude Desktop / ChatGPT at 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. +- **The API key is encrypted at rest and stays out of URLs and tokens.** The + access token is a reference token carrying only a `jti`; the key lives in the + Fernet-encrypted store keyed by that `jti`, and never appears in a redirect + URL. The user submits it once in the consent form POST, so serve the server + over HTTPS to protect it in transit. - **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. diff --git a/examples/auth/api_key_oauth/provider.py b/examples/auth/api_key_oauth/provider.py index 20152f050..5f1472109 100644 --- a/examples/auth/api_key_oauth/provider.py +++ b/examples/auth/api_key_oauth/provider.py @@ -23,8 +23,9 @@ is built on: 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. + rest. The key never appears in a URL or in the issued token; `load_access_token` + validates the JWT and looks the key back up. (The user submits it once in the + consent POST, so serve the server over HTTPS.) Tools read the key from the access token claims (e.g. via `CurrentAccessToken`). @@ -467,7 +468,7 @@ class APIKeyOAuthProvider(OAuthProvider): 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. + # via get_access_token(). It lives only in memory here, never in the token. return AccessToken( token=token, client_id=payload.get("client_id", ""),