mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 15:19:10 +02:00
Add platform-aware OAuth token persistence (#2218)
* Add comprehensive keyring integration tests Prevents OS keyring pollution during testing by adding a global mock in conftest.py. Tests verify keyring behavior across platforms and fallback scenarios without writing to the actual system keyring. - Add global mock_keyring fixture to tests/conftest.py - Add TestOAuthProxyKeyring class with 6 keyring-specific tests - Remove try/except ImportError for keyring (now required dependency) - Add keyring extra to py-key-value-aio dependency - Clean up extraneous implementation comments in oauth_proxy.py * Update OAuth keyring documentation Update all OAuth-related documentation to reflect keyring-based key management: - Add version badges to jwt_signing_key, token_encryption_key, and client_storage parameters - Standardize "Default behavior (`None`):" formatting with backticks - Ensure consistent messaging about development-only defaults across all docs - Update oauth-proxy.mdx, oidc-proxy.mdx, http.mdx, storage-backends.mdx, and upgrade-guide.mdx
This commit is contained in:
parent
df7a485869
commit
686082a5b5
11 changed files with 669 additions and 100 deletions
|
|
@ -545,24 +545,29 @@ MCP_AUTH_TOKEN=secret uvicorn app:app --host 0.0.0.0 --port 8000
|
|||
|
||||
<VersionBadge version="2.13.0" />
|
||||
|
||||
If you're using the [OAuth Proxy](/servers/auth/oauth-proxy), FastMCP issues its own JWT tokens to clients instead of forwarding upstream provider tokens. This maintains proper OAuth 2.0 token boundaries, but requires specific production configuration to ensure tokens survive server restarts.
|
||||
If you're using the [OAuth Proxy](/servers/auth/oauth-proxy), FastMCP issues its own JWT tokens to clients instead of forwarding upstream provider tokens. This maintains proper OAuth 2.0 token boundaries.
|
||||
|
||||
**Development vs Production:**
|
||||
**Default Behavior (Development Only):**
|
||||
|
||||
By default, token cryptographic keys are ephemeral—generated from a random salt at startup and not persisted anywhere. This means keys change on every restart, invalidating all tokens and triggering client re-authentication. This works fine for development and testing where re-auth after restart is acceptable.
|
||||
By default, FastMCP automatically manages cryptographic keys:
|
||||
- **Mac/Windows**: Keys are generated and stored in your system keyring, surviving server restarts. Suitable **only** for development and local testing.
|
||||
- **Linux**: Keys are ephemeral (random salt at startup), so tokens are invalidated on restart.
|
||||
|
||||
For production, tokens should survive restarts to avoid disrupting clients. This requires four things working together:
|
||||
This automatic approach is convenient for development but not suitable for production deployments.
|
||||
|
||||
**For Production:**
|
||||
|
||||
Production requires explicit key management to ensure tokens survive restarts and can be shared across multiple server instances. This requires three things working together:
|
||||
|
||||
1. **Explicit JWT signing key** for signing tokens issued to clients
|
||||
2. **Explicit token encryption key** for encrypting upstream OAuth tokens at rest
|
||||
3. **Persistent storage** so encrypted upstream tokens survive restart
|
||||
4. **HTTPS deployment** for secure cookie handling
|
||||
3. **Persistent network-accessible storage** for encrypted upstream tokens
|
||||
|
||||
The two keys can be any secret strings (environment variables, secret manager, etc.) and should be different from each other. FastMCP derives proper cryptographic keys from whatever you provide using HKDF.
|
||||
The keys can be any secret strings (environment variables, secret manager, etc.) and should be different from each other. FastMCP derives proper cryptographic keys from whatever you provide using HKDF.
|
||||
|
||||
**Configuration:**
|
||||
|
||||
Add two parameters to your auth provider and use persistent storage and HTTPS:
|
||||
Add three parameters to your auth provider:
|
||||
|
||||
```python {4-7}
|
||||
auth = GitHubProvider(
|
||||
|
|
@ -571,13 +576,13 @@ auth = GitHubProvider(
|
|||
jwt_signing_key=os.environ["JWT_SIGNING_KEY"],
|
||||
token_encryption_key=os.environ["TOKEN_ENCRYPTION_KEY"],
|
||||
client_storage=RedisStore(host="redis.example.com", ...),
|
||||
base_url="https://your-server.com" # use HTTPS
|
||||
base_url="https://your-server.com" # use HTTPS
|
||||
)
|
||||
```
|
||||
|
||||
Without explicit keys, new keys are generated each time the server starts. Without persistent storage, encrypted tokens are lost. Both cause token validation to fail after restart, requiring all clients to re-authenticate.
|
||||
All three parameters are required for production. Without explicit keys, new keys are generated each time the server starts (on Mac/Windows from keyring, on Linux ephemeral). Without persistent storage, encrypted tokens are lost. Both cause token validation to fail after restart, requiring all clients to re-authenticate.
|
||||
|
||||
For more details on the token architecture, see [OAuth Proxy Token Architecture](/servers/auth/oauth-proxy#token-architecture).
|
||||
For more details on the token architecture and key management, see [OAuth Proxy Key and Storage Management](/servers/auth/oauth-proxy#key-and-storage-management).
|
||||
|
||||
## Testing Your Deployment
|
||||
|
||||
|
|
|
|||
|
|
@ -18,11 +18,17 @@ The OAuth proxy now issues its own JWT tokens to clients instead of forwarding u
|
|||
|
||||
The OAuth proxy now implements a token factory pattern - it receives tokens from your OAuth provider (GitHub, Google, etc.), encrypts and stores them, then issues its own FastMCP JWT tokens to clients. This requires cryptographic keys for JWT signing and token encryption.
|
||||
|
||||
By default, these keys are ephemeral (random salt at startup, not persisted). For most users (development/testing), this works fine since re-authentication after restart is acceptable. For production deployments where you want tokens to persist across restarts, provide explicit keys via parameters.
|
||||
**Default behavior (development):**
|
||||
|
||||
**Production deployments:**
|
||||
By default, FastMCP automatically manages keys based on your platform:
|
||||
- **Mac/Windows**: Keys are auto-managed via system keyring, surviving server restarts with zero configuration. Suitable **only** for development and local testing.
|
||||
- **Linux**: Keys are ephemeral (random salt at startup, regenerated on each restart).
|
||||
|
||||
If you want tokens to survive server restarts, add two new parameters:
|
||||
This works fine for development and testing where re-authentication after restart is acceptable.
|
||||
|
||||
**For production:**
|
||||
|
||||
Production deployments must provide explicit keys and use persistent storage. Add these three things:
|
||||
|
||||
```python
|
||||
auth = GitHubProvider(
|
||||
|
|
@ -30,16 +36,18 @@ auth = GitHubProvider(
|
|||
client_secret=os.environ["GITHUB_CLIENT_SECRET"],
|
||||
base_url="https://your-server.com",
|
||||
|
||||
# Add these for production token persistence
|
||||
# Explicit keys (required for production)
|
||||
jwt_signing_key=os.environ["JWT_SIGNING_KEY"],
|
||||
token_encryption_key=os.environ["TOKEN_ENCRYPTION_KEY"],
|
||||
|
||||
client_storage=RedisStore(...) # Persistent storage
|
||||
# Persistent network storage (required for production)
|
||||
client_storage=RedisStore(host="redis.example.com", port=6379)
|
||||
)
|
||||
```
|
||||
|
||||
Both keys accept any secret string. Make sure they're different from each other.
|
||||
All three are required for production. The keys accept any secret string and should be different from each other.
|
||||
|
||||
**More information:**
|
||||
- [OAuth Token Security](/deployment/http#oauth-token-security) - Complete production setup guide
|
||||
- [Key and Storage Management](/servers/auth/oauth-proxy#key-and-storage-management) - Detailed explanation of defaults and production requirements
|
||||
- [OAuth Proxy Parameters](/servers/auth/oauth-proxy#configuration-parameters) - Parameter documentation
|
||||
|
|
|
|||
|
|
@ -211,9 +211,17 @@ These parameters are included in all token requests to the upstream provider.
|
|||
</ParamField>
|
||||
|
||||
<ParamField body="client_storage" type="AsyncKeyValue | None">
|
||||
Storage backend for persisting OAuth client registrations and encrypted upstream tokens. By default, clients are automatically persisted to disk in `~/.config/fastmcp/oauth-proxy-clients/`, allowing them to survive server restarts as long as the filesystem remains accessible. This means MCP clients only need to register once and can reconnect seamlessly after your server restarts.
|
||||
|
||||
For production deployments with multiple servers or cloud deployments, see [Storage Backends](/servers/storage-backends) for options including Redis, DynamoDB, and custom implementations.
|
||||
<VersionBadge version="2.13.0" />
|
||||
Storage backend for persisting OAuth client registrations and encrypted upstream tokens.
|
||||
|
||||
**Default behavior:**
|
||||
- **Mac/Windows**: DiskStore in your platform's data directory (derived from `platformdirs`)
|
||||
- **Linux**: MemoryStore (ephemeral - clients lost on restart)
|
||||
|
||||
By default on Mac/Windows, clients are automatically persisted to disk, allowing them to survive server restarts as long as the filesystem remains accessible. This means MCP clients only need to register once and can reconnect seamlessly. On Linux where keyring isn't available, ephemeral storage is used to match the ephemeral key strategy.
|
||||
|
||||
For production deployments with multiple servers or cloud deployments, see [Storage Backends](/servers/storage-backends) for available options.
|
||||
|
||||
For production token persistence, use this with `jwt_signing_key` and `token_encryption_key` - all three work together to ensure tokens survive restarts. See [OAuth Token Security](/deployment/http#oauth-token-security).
|
||||
|
||||
|
|
@ -243,13 +251,16 @@ auth = OAuthProxy(
|
|||
</ParamField>
|
||||
|
||||
<ParamField body="jwt_signing_key" type="str | bytes | None">
|
||||
|
||||
<VersionBadge version="2.13.0" />
|
||||
Secret used to sign FastMCP JWT tokens issued to clients. Accepts any string or bytes - will be derived into a proper 32-byte cryptographic key using HKDF.
|
||||
|
||||
**Default behavior (None):**
|
||||
Keys are ephemeral (random salt at startup). Tokens become invalid on server restart, triggering client re-authentication. This is fine for development and testing.
|
||||
**Default behavior (`None`):**
|
||||
- **Mac/Windows**: Auto-managed via system keyring. Keys are generated once and persisted, surviving server restarts with zero configuration. Keys are automatically derived from server attributes, so this approach, while convenient, is **only** suitable for development and local testing. For production, you must provide an explicit secret.
|
||||
- **Linux**: Ephemeral (random salt at startup). Tokens become invalid on server restart, triggering client re-authentication.
|
||||
|
||||
**Production:**
|
||||
Provide an explicit secret (e.g., from environment variable). Works with `token_encryption_key` and `client_storage` to ensure tokens survive restarts - all three parameters are required for production token persistence.
|
||||
**For production:**
|
||||
Provide an explicit secret (e.g., from environment variable) to use a fixed key instead of the auto-generated one. Works with `token_encryption_key` and `client_storage` to ensure tokens survive restarts - all three parameters are required for production deployments. This allows you to manage keys securely in cloud environments and across multiple instances.
|
||||
|
||||
```python
|
||||
import os
|
||||
|
|
@ -266,13 +277,16 @@ auth = OAuthProxy(
|
|||
</ParamField>
|
||||
|
||||
<ParamField body="token_encryption_key" type="str | bytes | None">
|
||||
|
||||
<VersionBadge version="2.13.0" />
|
||||
Secret used to encrypt upstream tokens at rest in `client_storage`. Accepts any string or bytes - will be derived into a proper 32-byte cryptographic key using HKDF.
|
||||
|
||||
**Default behavior (None):**
|
||||
Like `jwt_signing_key`, this is ephemeral. However, without a valid JWT signing key, encrypted tokens are useless anyway (JWT validation fails first).
|
||||
**Default behavior (`None`):**
|
||||
- **Mac/Windows**: FastMCP will generate a key and store it in the system's keyring. Keys are automatically derived from server attributes, so this approach, while convenient, is **only** suitable for development and local testing. For production, you must provide an explicit secret.
|
||||
- **Linux**: Ephemeral (random salt at startup). Like `jwt_signing_key`, this is ephemeral, though without a valid JWT signing key, encrypted tokens are useless anyway (JWT validation fails first).
|
||||
|
||||
**Production:**
|
||||
Provide an explicit secret distinct from `jwt_signing_key`. Works with `jwt_signing_key` and persistent `client_storage` - all three are required for production token persistence.
|
||||
**For production:**
|
||||
Provide an explicit secret distinct from `jwt_signing_key`. Works with `jwt_signing_key` and persistent `client_storage` - all three are required for production deployments.
|
||||
|
||||
```python
|
||||
# Use different secrets for each key
|
||||
|
|
@ -467,12 +481,6 @@ FastMCP token lifetimes match the upstream token lifetimes. When the upstream to
|
|||
|
||||
The proxy issues its own refresh tokens that map to upstream refresh tokens. When a client uses a FastMCP refresh token, the proxy refreshes the upstream token and issues a new FastMCP access token.
|
||||
|
||||
**Key and storage configuration:**
|
||||
|
||||
The token architecture requires cryptographic keys for JWT signing and token encryption. By default, these keys are ephemeral—generated from a random salt at startup and not persisted. This means tokens become invalid on server restart, requiring clients to re-authenticate. For development and testing, this is acceptable.
|
||||
|
||||
For production, configure three parameters together: `jwt_signing_key` (for signing FastMCP JWTs), `token_encryption_key` (for encrypting upstream tokens at rest), and persistent `client_storage` (for storing encrypted tokens). All three are required for tokens to survive server restarts. The keys accept any secret string and derive proper cryptographic keys using HKDF. See [OAuth Token Security](/deployment/http#oauth-token-security) for complete production setup.
|
||||
|
||||
### PKCE Forwarding
|
||||
|
||||
The OAuth proxy automatically handles PKCE (Proof Key for Code Exchange) when working with providers that support or require it. The proxy generates its own PKCE parameters to send upstream while separately validating the client's PKCE, ensuring end-to-end security at both layers.
|
||||
|
|
@ -516,6 +524,18 @@ Check your server logs for "Client registered with redirect_uri" messages to ide
|
|||
|
||||
## Security
|
||||
|
||||
### Key and Storage Management
|
||||
|
||||
<VersionBadge version="2.13.0" />
|
||||
The OAuth proxy requires cryptographic keys for JWT signing and token encryption, plus persistent storage to maintain valid tokens across server restarts.
|
||||
|
||||
**Default behavior (appropriate for development only):**
|
||||
- **Mac/Windows**: FastMCP automatically generates keys and stores them in your system keyring. Storage defaults to disk. Tokens survive server restarts. This is **only** suitable for development and local testing.
|
||||
- **Linux**: Keys are ephemeral (random salt at startup). Storage defaults to memory. Tokens become invalid on server restart.
|
||||
|
||||
**For production:**
|
||||
Configure three parameters together: provide a unique `jwt_signing_key` (for signing FastMCP JWTs), a unique `token_encryption_key` (for encrypting upstream tokens at rest), and a shared `client_storage` backend (for storing encrypted tokens). All three are required for production deployments. Use a network-accessible storage backend like Redis or DynamoDB rather than local disk storage. The keys accept any secret string and derive proper cryptographic keys using HKDF. See [OAuth Token Security](/deployment/http#oauth-token-security) and [Storage Backends](/servers/storage-backends) for complete production setup.
|
||||
|
||||
### Confused Deputy Attacks
|
||||
|
||||
<VersionBadge version="2.13.0" />
|
||||
|
|
|
|||
|
|
@ -129,14 +129,52 @@ Set this if your provider requires a specific authentication method and the defa
|
|||
|
||||
</ParamField>
|
||||
|
||||
<ParamField body="client_storage" type="KVStorage | None">
|
||||
Storage backend for persisting OAuth client registrations. By default, clients are automatically persisted to disk in `~/.config/fastmcp/oidc-proxy-clients/`, allowing them to survive server restarts as long as the filesystem remains accessible. This means MCP clients only need to register once and can reconnect seamlessly after your server restarts.
|
||||
<ParamField body="jwt_signing_key" type="str | bytes | None">
|
||||
|
||||
<VersionBadge version="2.13.0" />
|
||||
Secret used to sign FastMCP JWT tokens issued to clients. Accepts any string or bytes - will be derived into a proper 32-byte cryptographic key using HKDF.
|
||||
|
||||
**Default behavior (`None`):**
|
||||
- **Mac/Windows**: Auto-managed via system keyring. Keys are generated once and persisted, surviving server restarts with zero configuration. Keys are automatically derived from server attributes, so this approach, while convenient, is **only** suitable for development and local testing. For production, you must provide an explicit secret.
|
||||
- **Linux**: Ephemeral (random salt at startup). Tokens become invalid on server restart, triggering client re-authentication.
|
||||
|
||||
**For production:**
|
||||
Provide an explicit secret (e.g., from environment variable) to use a fixed key instead of the auto-generated one. Works with `token_encryption_key` and `client_storage` to ensure tokens survive restarts - all three parameters are required for production deployments.
|
||||
</ParamField>
|
||||
|
||||
<ParamField body="token_encryption_key" type="str | bytes | None">
|
||||
|
||||
<VersionBadge version="2.13.0" />
|
||||
Secret used to encrypt upstream tokens at rest in `client_storage`. Accepts any string or bytes - will be derived into a proper 32-byte cryptographic key using HKDF.
|
||||
|
||||
**Default behavior (`None`):**
|
||||
- **Mac/Windows**: FastMCP will generate a key and store it in the system's keyring. Keys are automatically derived from server attributes, so this approach, while convenient, is **only** suitable for development and local testing. For production, you must provide an explicit secret.
|
||||
- **Linux**: Ephemeral (random salt at startup). Like `jwt_signing_key`, this is ephemeral, though without a valid JWT signing key, encrypted tokens are useless anyway (JWT validation fails first).
|
||||
|
||||
**For production:**
|
||||
Provide an explicit secret distinct from `jwt_signing_key`. Works with `jwt_signing_key` and persistent `client_storage` - all three are required for production deployments.
|
||||
</ParamField>
|
||||
|
||||
<ParamField body="client_storage" type="AsyncKeyValue | None">
|
||||
|
||||
<VersionBadge version="2.13.0" />
|
||||
Storage backend for persisting OAuth client registrations and encrypted upstream tokens.
|
||||
|
||||
**Default behavior:**
|
||||
- **Mac/Windows**: DiskStore in your platform's data directory (derived from `platformdirs`)
|
||||
- **Linux**: MemoryStore (ephemeral - clients lost on restart)
|
||||
|
||||
By default on Mac/Windows, clients are automatically persisted to disk, allowing them to survive server restarts as long as the filesystem remains accessible. This means MCP clients only need to register once and can reconnect seamlessly. On Linux where keyring isn't available, ephemeral storage is used to match the ephemeral key strategy.
|
||||
|
||||
For production deployments with multiple servers or cloud deployments, use a network-accessible storage backend rather than local disk storage. See [Storage Backends](/servers/storage-backends) for available options.
|
||||
|
||||
Testing with in-memory storage:
|
||||
|
||||
```python
|
||||
from fastmcp.utilities.storage import InMemoryStorage
|
||||
from key_value.aio.stores.memory import MemoryStore
|
||||
|
||||
# Use in-memory storage for testing (clients lost on restart)
|
||||
auth = OIDCProxy(..., client_storage=InMemoryStorage())
|
||||
auth = OIDCProxy(..., client_storage=MemoryStore())
|
||||
```
|
||||
|
||||
</ParamField>
|
||||
|
|
|
|||
|
|
@ -148,10 +148,17 @@ For configuration details on these backends, consult the [py-key-value-aio docum
|
|||
|
||||
### Server-Side OAuth Token Storage
|
||||
|
||||
The [OAuth Proxy](/servers/auth/oauth-proxy) and OAuth auth providers use storage for persisting OAuth client registrations and encrypted upstream tokens. By default, registrations are stored in memory:
|
||||
The [OAuth Proxy](/servers/auth/oauth-proxy) and OAuth auth providers use storage for persisting OAuth client registrations and encrypted upstream tokens.
|
||||
|
||||
**Development (default behavior):**
|
||||
|
||||
By default, FastMCP automatically manages keys and storage based on your platform:
|
||||
- **Mac/Windows**: Keys are auto-managed via system keyring, storage defaults to disk. Suitable **only** for development and local testing.
|
||||
- **Linux**: Keys are ephemeral, storage defaults to memory.
|
||||
|
||||
No configuration needed:
|
||||
|
||||
```python
|
||||
# In-memory storage (default behavior - lost on restart)
|
||||
from fastmcp.server.auth.providers.github import GitHubProvider
|
||||
|
||||
auth = GitHubProvider(
|
||||
|
|
@ -161,7 +168,9 @@ auth = GitHubProvider(
|
|||
)
|
||||
```
|
||||
|
||||
For production with token persistence across restarts, configure persistent storage and encryption keys:
|
||||
**Production:**
|
||||
|
||||
For production deployments, configure explicit keys and persistent network-accessible storage:
|
||||
|
||||
```python
|
||||
import os
|
||||
|
|
@ -172,15 +181,15 @@ auth = GitHubProvider(
|
|||
client_id=os.environ["GITHUB_CLIENT_ID"],
|
||||
client_secret=os.environ["GITHUB_CLIENT_SECRET"],
|
||||
base_url="https://your-server.com",
|
||||
# Token encryption and signing keys
|
||||
# Explicit token encryption and signing keys (required for production)
|
||||
jwt_signing_key=os.environ["JWT_SIGNING_KEY"],
|
||||
token_encryption_key=os.environ["TOKEN_ENCRYPTION_KEY"],
|
||||
# Persistent distributed storage
|
||||
# Persistent distributed storage (required for production)
|
||||
client_storage=RedisStore(host="redis.example.com", port=6379)
|
||||
)
|
||||
```
|
||||
|
||||
See [OAuth Token Security](/deployment/http#oauth-token-security) for complete production setup details.
|
||||
All three parameters (both keys and storage) are required for production. See [OAuth Token Security](/deployment/http#oauth-token-security) and [Key and Storage Management](/servers/auth/oauth-proxy#key-and-storage-management) for complete setup details.
|
||||
|
||||
### Response Caching Middleware
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ dependencies = [
|
|||
"pydantic[email]>=2.11.7",
|
||||
"pyperclip>=1.9.0",
|
||||
"openapi-core>=0.19.5",
|
||||
"py-key-value-aio[disk,memory]>=0.2.6,<0.3.0",
|
||||
"py-key-value-aio[disk,memory,keyring]>=0.2.6,<0.3.0",
|
||||
"websockets>=15.0.1",
|
||||
"pytest-asyncio>=1.2.0",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import base64
|
|||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import platform
|
||||
import secrets
|
||||
import time
|
||||
from base64 import urlsafe_b64encode
|
||||
|
|
@ -33,6 +34,7 @@ from authlib.common.security import generate_token
|
|||
from authlib.integrations.httpx_client import AsyncOAuth2Client
|
||||
from key_value.aio.adapters.pydantic import PydanticAdapter
|
||||
from key_value.aio.protocols import AsyncKeyValue
|
||||
from key_value.aio.stores.disk import DiskStore
|
||||
from key_value.aio.stores.memory import MemoryStore
|
||||
from mcp.server.auth.handlers.token import TokenErrorResponse, TokenSuccessResponse
|
||||
from mcp.server.auth.handlers.token import TokenHandler as _SDKTokenHandler
|
||||
|
|
@ -56,6 +58,7 @@ from starlette.requests import Request
|
|||
from starlette.responses import HTMLResponse, RedirectResponse
|
||||
from starlette.routing import Route
|
||||
|
||||
from fastmcp import settings
|
||||
from fastmcp.server.auth.auth import OAuthProvider, TokenVerifier
|
||||
from fastmcp.server.auth.jwt_issuer import (
|
||||
JWTIssuer,
|
||||
|
|
@ -64,6 +67,7 @@ from fastmcp.server.auth.jwt_issuer import (
|
|||
from fastmcp.server.auth.redirect_validation import (
|
||||
validate_redirect_uri,
|
||||
)
|
||||
from fastmcp.utilities.key_management import get_or_generate_keyring_key
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
from fastmcp.utilities.ui import (
|
||||
BUTTON_STYLES,
|
||||
|
|
@ -597,13 +601,16 @@ class OAuthProxy(OAuthProvider):
|
|||
Example: {"audience": "https://api.example.com"}
|
||||
extra_token_params: Additional parameters to forward to the upstream token endpoint.
|
||||
Useful for provider-specific parameters during token exchange.
|
||||
client_storage: An AsyncKeyValue-compatible store for client registrations, registrations are stored in memory if not provided
|
||||
jwt_signing_key: Optional secret for signing FastMCP JWT tokens (accepts any string or bytes).
|
||||
Default: ephemeral (random salt at startup, won't survive restart).
|
||||
Production: provide explicit key from environment variable.
|
||||
token_encryption_key: Optional secret for encrypting upstream tokens at rest (accepts any string or bytes).
|
||||
Default: ephemeral (random salt at startup, won't survive restart).
|
||||
Production: provide explicit key from environment variable.
|
||||
client_storage: Storage backend for OAuth state (client registrations, encrypted tokens).
|
||||
Default (Mac/Windows): DiskStore at $FASTMCP_HOME/oauth-proxy (~/.fastmcp/oauth-proxy).
|
||||
Default (Linux): MemoryStore (ephemeral keys make persistence pointless).
|
||||
Custom: Pass DiskStore/RedisStore instance or override location via FASTMCP_HOME.
|
||||
jwt_signing_key: Secret for signing FastMCP JWT tokens (any string or bytes).
|
||||
None (default): Auto-managed via system keyring (Mac/Windows) or ephemeral (Linux).
|
||||
Explicit value: For production deployments. Recommended to store in environment variable.
|
||||
token_encryption_key: Secret for encrypting upstream tokens at rest (any string or bytes).
|
||||
None (default): Auto-managed via system keyring (Mac/Windows) or ephemeral (Linux).
|
||||
Explicit value: For production deployments. Recommended to store in environment variable.
|
||||
require_authorization_consent: Whether to require user consent before authorizing clients (default True).
|
||||
When True, users see a consent screen before being redirected to the upstream IdP.
|
||||
When False, authorization proceeds directly without user confirmation.
|
||||
|
|
@ -673,15 +680,41 @@ class OAuthProxy(OAuthProvider):
|
|||
self._extra_authorize_params = extra_authorize_params or {}
|
||||
self._extra_token_params = extra_token_params or {}
|
||||
|
||||
self._client_storage: AsyncKeyValue = client_storage or MemoryStore()
|
||||
# Default storage: match persistence to key availability
|
||||
# On Mac/Windows: DiskStore + keyring keys = full persistence
|
||||
# On Linux: MemoryStore + ephemeral keys = consistent (nothing persists)
|
||||
if client_storage is None:
|
||||
if platform.system() != "Linux":
|
||||
# Keyring available: use persistent storage
|
||||
default_storage_path = settings.home / "oauth-proxy"
|
||||
default_storage_path.mkdir(parents=True, exist_ok=True)
|
||||
self._client_storage = DiskStore(directory=str(default_storage_path))
|
||||
logger.debug(
|
||||
"Using disk storage for OAuth state: %s", default_storage_path
|
||||
)
|
||||
else:
|
||||
# Keyring unavailable: use memory storage (ephemeral keys make disk pointless)
|
||||
self._client_storage = MemoryStore()
|
||||
logger.debug(
|
||||
"Using in-memory storage on Linux (keyring unavailable). "
|
||||
"For persistent tokens, provide explicit jwt_signing_key, "
|
||||
"token_encryption_key, and client_storage."
|
||||
)
|
||||
self._auto_selected_storage = True
|
||||
else:
|
||||
self._client_storage = client_storage
|
||||
self._auto_selected_storage = False
|
||||
|
||||
# Warn if using MemoryStore in production
|
||||
if isinstance(client_storage, MemoryStore):
|
||||
# Warn if explicitly using MemoryStore when keyring is available
|
||||
if (
|
||||
isinstance(self._client_storage, MemoryStore)
|
||||
and not self._auto_selected_storage
|
||||
and platform.system() != "Linux"
|
||||
):
|
||||
logger.warning(
|
||||
"Using in-memory storage - all OAuth state (clients, tokens) will be lost on restart. "
|
||||
"Additionally, without explicit jwt_signing_key and token_encryption_key, "
|
||||
"keys are ephemeral and tokens won't survive restart even with persistent storage. "
|
||||
"For production, configure persistent storage AND explicit keys."
|
||||
"Using in-memory storage on a platform with keyring support. "
|
||||
"OAuth state will be lost on restart. Consider using default storage "
|
||||
"or providing explicit jwt_signing_key and token_encryption_key with persistent storage."
|
||||
)
|
||||
|
||||
# Cache HTTPS check to avoid repeated logging
|
||||
|
|
@ -780,20 +813,13 @@ class OAuthProxy(OAuthProvider):
|
|||
"""Initialize JWT issuer and token encryption (lazy initialization).
|
||||
|
||||
Key derivation strategy:
|
||||
- Default: Generate random salt at startup, derive ephemeral keys
|
||||
→ Keys change on restart, all tokens become invalid
|
||||
→ Perfect for development/testing where re-auth is acceptable
|
||||
|
||||
- Production: User provides explicit keys via parameters
|
||||
→ Keys stable across restarts when combined with persistent storage
|
||||
→ Tokens survive restart, seamless client reconnection
|
||||
- Explicit key (production): User-provided via parameters
|
||||
- Keyring key (local/dev): Auto-managed via system keyring
|
||||
- Ephemeral key (fallback): Random salt at startup when keyring unavailable
|
||||
"""
|
||||
if self._jwt_initialized:
|
||||
return
|
||||
|
||||
# Generate random salt for this server instance (NOT persisted)
|
||||
server_salt = secrets.token_urlsafe(32)
|
||||
|
||||
# Derive or use custom JWT signing key
|
||||
from fastmcp.server.auth.jwt_issuer import derive_key_from_secret
|
||||
|
||||
|
|
@ -803,19 +829,40 @@ class OAuthProxy(OAuthProvider):
|
|||
salt="fastmcp-jwt-signing-v1",
|
||||
info=b"HS256",
|
||||
)
|
||||
logger.info("Using explicit JWT signing key (will survive restarts)")
|
||||
logger.debug("Using user-provided JWT signing key")
|
||||
else:
|
||||
# Ephemeral key from random salt + upstream secret
|
||||
upstream_secret = self._upstream_client_secret.get_secret_value()
|
||||
jwt_key = derive_key_from_secret(
|
||||
secret=upstream_secret,
|
||||
salt=f"fastmcp-jwt-signing-v1-{server_salt}",
|
||||
info=b"HS256",
|
||||
)
|
||||
logger.info(
|
||||
"Using ephemeral JWT signing key - tokens will NOT survive server restart. "
|
||||
"For production, provide explicit jwt_signing_key parameter and use persistent storage."
|
||||
keyring_key = get_or_generate_keyring_key(
|
||||
"jwt-signing", self._upstream_client_id
|
||||
)
|
||||
if keyring_key:
|
||||
jwt_key = derive_key_from_secret(
|
||||
secret=keyring_key,
|
||||
salt="fastmcp-jwt-signing-v1",
|
||||
info=b"HS256",
|
||||
)
|
||||
else:
|
||||
server_salt = secrets.token_urlsafe(32)
|
||||
upstream_secret = self._upstream_client_secret.get_secret_value()
|
||||
jwt_key = derive_key_from_secret(
|
||||
secret=upstream_secret,
|
||||
salt=f"fastmcp-jwt-signing-v1-{server_salt}",
|
||||
info=b"HS256",
|
||||
)
|
||||
|
||||
if platform.system() == "Linux":
|
||||
logger.warning(
|
||||
"Keyring unavailable on Linux - using ephemeral keys. "
|
||||
"Storage persists at %s but tokens will become unreadable after restart. "
|
||||
"For persistent tokens, provide explicit jwt_signing_key and token_encryption_key.",
|
||||
self._client_storage
|
||||
if hasattr(self, "_client_storage")
|
||||
else "disk",
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Keyring unavailable - using ephemeral keys. "
|
||||
"For production, provide explicit jwt_signing_key and token_encryption_key."
|
||||
)
|
||||
|
||||
# Initialize JWT issuer
|
||||
issuer = str(self.base_url)
|
||||
|
|
@ -826,29 +873,34 @@ class OAuthProxy(OAuthProvider):
|
|||
signing_key=jwt_key,
|
||||
)
|
||||
|
||||
# Derive or use custom encryption key
|
||||
if self._custom_encryption_key:
|
||||
encryption_key = derive_key_from_secret(
|
||||
secret=self._custom_encryption_key,
|
||||
salt="fastmcp-token-encryption-v1",
|
||||
info=b"Fernet",
|
||||
)
|
||||
# Fernet needs base64url-encoded key
|
||||
encryption_key = base64.urlsafe_b64encode(encryption_key)
|
||||
logger.info("Using explicit token encryption key (will survive restarts)")
|
||||
logger.debug("Using user-provided token encryption key")
|
||||
else:
|
||||
# Ephemeral key from random salt + upstream secret
|
||||
upstream_secret = self._upstream_client_secret.get_secret_value()
|
||||
key_material = derive_key_from_secret(
|
||||
secret=upstream_secret,
|
||||
salt=f"fastmcp-token-encryption-v1-{server_salt}",
|
||||
info=b"Fernet",
|
||||
)
|
||||
encryption_key = base64.urlsafe_b64encode(key_material)
|
||||
logger.info(
|
||||
"Using ephemeral token encryption key - encrypted tokens will NOT survive server restart. "
|
||||
"For production, provide explicit token_encryption_key parameter and use persistent storage."
|
||||
encryption_keyring_key = get_or_generate_keyring_key(
|
||||
"token-encryption", self._upstream_client_id
|
||||
)
|
||||
if encryption_keyring_key:
|
||||
key_material = derive_key_from_secret(
|
||||
secret=encryption_keyring_key,
|
||||
salt="fastmcp-token-encryption-v1",
|
||||
info=b"Fernet",
|
||||
)
|
||||
encryption_key = base64.urlsafe_b64encode(key_material)
|
||||
else:
|
||||
server_salt = secrets.token_urlsafe(32)
|
||||
upstream_secret = self._upstream_client_secret.get_secret_value()
|
||||
key_material = derive_key_from_secret(
|
||||
secret=upstream_secret,
|
||||
salt=f"fastmcp-token-encryption-v1-{server_salt}",
|
||||
info=b"Fernet",
|
||||
)
|
||||
encryption_key = base64.urlsafe_b64encode(key_material)
|
||||
|
||||
self._token_encryption = TokenEncryption(encryption_key)
|
||||
self._jwt_initialized = True
|
||||
|
|
|
|||
76
src/fastmcp/utilities/key_management.py
Normal file
76
src/fastmcp/utilities/key_management.py
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
"""Key management utilities for FastMCP.
|
||||
|
||||
Provides automatic key generation and storage in system keyring for
|
||||
Mac/Windows platforms, with graceful fallback for Linux/headless systems.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import platform
|
||||
import secrets
|
||||
|
||||
import keyring
|
||||
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def get_or_generate_keyring_key(key_type: str, namespace: str) -> str | None:
|
||||
"""Get or generate a key from the system keyring.
|
||||
|
||||
Keys are namespaced to allow multiple isolated key sets.
|
||||
|
||||
Args:
|
||||
key_type: Type of key (e.g., "jwt-signing", "token-encryption", "api-key")
|
||||
namespace: Unique identifier for this key set (e.g., client ID, server name)
|
||||
|
||||
Returns:
|
||||
Base64-encoded key string, or None if keyring unavailable
|
||||
|
||||
Example:
|
||||
>>> key = get_or_generate_keyring_key("jwt-signing", "my-github-client-id")
|
||||
>>> # Returns key from keyring or generates new one
|
||||
"""
|
||||
# Linux keyring support is unreliable (GUI sessions, unlock prompts, backend issues)
|
||||
if platform.system() == "Linux":
|
||||
return None
|
||||
|
||||
service_name = "fastmcp"
|
||||
# Namespace keys for isolation
|
||||
key_name = f"{key_type}-{namespace}"
|
||||
|
||||
try:
|
||||
# Try to get existing key from keyring
|
||||
existing_key = keyring.get_password(service_name, key_name)
|
||||
if existing_key:
|
||||
logger.debug(
|
||||
"Retrieved %s for namespace=%s from system keyring",
|
||||
key_type,
|
||||
namespace,
|
||||
)
|
||||
return existing_key
|
||||
|
||||
# Generate new secure random key (32 bytes for Fernet/HMAC)
|
||||
key_bytes = secrets.token_bytes(32)
|
||||
key_b64 = base64.b64encode(key_bytes).decode()
|
||||
|
||||
# Store in keyring for future use
|
||||
keyring.set_password(service_name, key_name, key_b64)
|
||||
logger.info(
|
||||
"Generated new %s for namespace=%s and stored in system keyring",
|
||||
key_type,
|
||||
namespace,
|
||||
)
|
||||
return key_b64
|
||||
|
||||
except Exception as e:
|
||||
# Keyring backend may not be available (headless systems, permissions, etc.)
|
||||
logger.warning(
|
||||
"Failed to access system keyring for %s: %s. "
|
||||
"Will use ephemeral key (tokens will not survive restart).",
|
||||
key_type,
|
||||
e,
|
||||
)
|
||||
return None
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import socket
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
|
@ -21,6 +22,19 @@ def import_rich_rule():
|
|||
yield
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_keyring():
|
||||
"""Globally mock keyring to prevent OS keyring pollution during tests.
|
||||
|
||||
This prevents any test from accidentally writing to the system keyring.
|
||||
Individual tests can override this mock if they need to test keyring behavior.
|
||||
"""
|
||||
with patch("fastmcp.utilities.key_management.keyring") as mock:
|
||||
# Return None by default (keyring unavailable)
|
||||
mock.get_password.return_value = None
|
||||
yield mock
|
||||
|
||||
|
||||
def get_fn_name(fn: Callable[..., Any]) -> str:
|
||||
return fn.__name__ # ty: ignore[unresolved-attribute]
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
"""Tests for OAuth proxy with persistent storage."""
|
||||
|
||||
import platform
|
||||
from collections.abc import AsyncGenerator
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
from diskcache.core import tempfile
|
||||
from inline_snapshot import snapshot
|
||||
from key_value.aio.stores.disk import MultiDiskStore
|
||||
from key_value.aio.stores.disk import DiskStore, MultiDiskStore
|
||||
from key_value.aio.stores.memory import MemoryStore
|
||||
from mcp.shared.auth import OAuthClientInformationFull
|
||||
from pydantic import AnyUrl
|
||||
|
|
@ -52,10 +53,15 @@ class TestOAuthProxyStorage:
|
|||
client_storage=storage,
|
||||
)
|
||||
|
||||
async def test_default_storage_is_file_based(self, jwt_verifier):
|
||||
"""Test that proxy defaults to file-based storage."""
|
||||
async def test_default_storage_is_platform_appropriate(self, jwt_verifier):
|
||||
"""Test that proxy defaults to appropriate storage for platform."""
|
||||
proxy = self.create_proxy(jwt_verifier, storage=None)
|
||||
assert isinstance(proxy._client_storage, MemoryStore)
|
||||
if platform.system() == "Linux":
|
||||
# Linux: no keyring support, use MemoryStore
|
||||
assert isinstance(proxy._client_storage, MemoryStore)
|
||||
else:
|
||||
# Mac/Windows: keyring available, use DiskStore
|
||||
assert isinstance(proxy._client_storage, DiskStore)
|
||||
|
||||
async def test_register_and_get_client(self, jwt_verifier, temp_storage):
|
||||
"""Test registering and retrieving a client."""
|
||||
|
|
@ -202,3 +208,226 @@ class TestOAuthProxyStorage:
|
|||
"allowed_redirect_uri_patterns": None,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class TestOAuthProxyKeyring:
|
||||
"""Tests for OAuth proxy keyring integration.
|
||||
|
||||
All tests mock keyring to prevent pollution of the OS keyring during testing.
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def jwt_verifier(self):
|
||||
"""Create a mock JWT verifier."""
|
||||
verifier = Mock()
|
||||
verifier.required_scopes = ["read", "write"]
|
||||
verifier.verify_token = AsyncMock(return_value=None)
|
||||
return verifier
|
||||
|
||||
@pytest.fixture
|
||||
def memory_storage(self) -> MemoryStore:
|
||||
"""Create in-memory storage for testing."""
|
||||
return MemoryStore()
|
||||
|
||||
@patch("fastmcp.utilities.key_management.platform.system")
|
||||
@patch("fastmcp.utilities.key_management.keyring")
|
||||
async def test_keyring_used_on_mac_windows(
|
||||
self, mock_keyring, mock_platform, jwt_verifier, memory_storage
|
||||
):
|
||||
"""Test that keyring is used on Mac/Windows platforms."""
|
||||
# Simulate Mac platform
|
||||
mock_platform.return_value = "Darwin"
|
||||
|
||||
# Mock keyring to return None (first time, no existing key)
|
||||
mock_keyring.get_password.return_value = None
|
||||
|
||||
# Create proxy without explicit keys (should use keyring)
|
||||
proxy = OAuthProxy(
|
||||
upstream_authorization_endpoint="https://github.com/login/oauth/authorize",
|
||||
upstream_token_endpoint="https://github.com/login/oauth/access_token",
|
||||
upstream_client_id="test-keyring-client",
|
||||
upstream_client_secret="test-secret",
|
||||
token_verifier=jwt_verifier,
|
||||
base_url="https://myserver.com",
|
||||
client_storage=memory_storage,
|
||||
)
|
||||
|
||||
# Trigger JWT initialization to activate keyring calls
|
||||
await proxy._ensure_jwt_initialized()
|
||||
|
||||
# Verify keyring was accessed for both JWT and encryption keys
|
||||
assert mock_keyring.get_password.call_count == 2
|
||||
assert mock_keyring.set_password.call_count == 2
|
||||
|
||||
# Verify service name and key names
|
||||
jwt_calls = [
|
||||
call
|
||||
for call in mock_keyring.get_password.call_args_list
|
||||
if "jwt-signing" in str(call)
|
||||
]
|
||||
encryption_calls = [
|
||||
call
|
||||
for call in mock_keyring.get_password.call_args_list
|
||||
if "token-encryption" in str(call)
|
||||
]
|
||||
|
||||
assert len(jwt_calls) == 1
|
||||
assert len(encryption_calls) == 1
|
||||
|
||||
# Check that keys were stored with correct service name
|
||||
set_calls = mock_keyring.set_password.call_args_list
|
||||
for call in set_calls:
|
||||
assert call[0][0] == "fastmcp" # service name
|
||||
assert "test-keyring-client" in call[0][1] # namespace in key name
|
||||
|
||||
@patch("fastmcp.utilities.key_management.platform.system")
|
||||
@patch("fastmcp.utilities.key_management.keyring")
|
||||
async def test_keyring_skipped_on_linux(
|
||||
self, mock_keyring, mock_platform, jwt_verifier, memory_storage
|
||||
):
|
||||
"""Test that keyring is skipped on Linux platforms."""
|
||||
# Simulate Linux platform
|
||||
mock_platform.return_value = "Linux"
|
||||
|
||||
# Create proxy without explicit keys
|
||||
OAuthProxy(
|
||||
upstream_authorization_endpoint="https://github.com/login/oauth/authorize",
|
||||
upstream_token_endpoint="https://github.com/login/oauth/access_token",
|
||||
upstream_client_id="linux-client",
|
||||
upstream_client_secret="test-secret",
|
||||
token_verifier=jwt_verifier,
|
||||
base_url="https://myserver.com",
|
||||
client_storage=memory_storage,
|
||||
)
|
||||
|
||||
# Keyring should never be accessed on Linux
|
||||
mock_keyring.get_password.assert_not_called()
|
||||
mock_keyring.set_password.assert_not_called()
|
||||
|
||||
@patch("fastmcp.utilities.key_management.platform.system")
|
||||
@patch("fastmcp.utilities.key_management.keyring")
|
||||
async def test_explicit_keys_bypass_keyring(
|
||||
self, mock_keyring, mock_platform, jwt_verifier, memory_storage
|
||||
):
|
||||
"""Test that explicit keys bypass keyring entirely."""
|
||||
mock_platform.return_value = "Darwin"
|
||||
|
||||
# Create proxy with explicit keys
|
||||
OAuthProxy(
|
||||
upstream_authorization_endpoint="https://github.com/login/oauth/authorize",
|
||||
upstream_token_endpoint="https://github.com/login/oauth/access_token",
|
||||
upstream_client_id="explicit-keys-client",
|
||||
upstream_client_secret="test-secret",
|
||||
token_verifier=jwt_verifier,
|
||||
base_url="https://myserver.com",
|
||||
jwt_signing_key="my-custom-jwt-key",
|
||||
token_encryption_key="my-custom-encryption-key",
|
||||
client_storage=memory_storage,
|
||||
)
|
||||
|
||||
# Keyring should never be accessed when explicit keys provided
|
||||
mock_keyring.get_password.assert_not_called()
|
||||
mock_keyring.set_password.assert_not_called()
|
||||
|
||||
@patch("fastmcp.utilities.key_management.platform.system")
|
||||
@patch("fastmcp.utilities.key_management.keyring")
|
||||
async def test_keyring_namespace_isolation(
|
||||
self, mock_keyring, mock_platform, jwt_verifier, memory_storage
|
||||
):
|
||||
"""Test that different upstream client IDs create isolated keyring entries."""
|
||||
mock_platform.return_value = "Darwin"
|
||||
mock_keyring.get_password.return_value = None
|
||||
|
||||
# Create first proxy with client-A
|
||||
OAuthProxy(
|
||||
upstream_authorization_endpoint="https://github.com/login/oauth/authorize",
|
||||
upstream_token_endpoint="https://github.com/login/oauth/access_token",
|
||||
upstream_client_id="client-A",
|
||||
upstream_client_secret="secret-A",
|
||||
token_verifier=jwt_verifier,
|
||||
base_url="https://myserver.com",
|
||||
client_storage=memory_storage,
|
||||
)
|
||||
|
||||
# Reset mock to track second proxy separately
|
||||
mock_keyring.reset_mock()
|
||||
mock_keyring.get_password.return_value = None
|
||||
|
||||
# Create second proxy with client-B
|
||||
OAuthProxy(
|
||||
upstream_authorization_endpoint="https://github.com/login/oauth/authorize",
|
||||
upstream_token_endpoint="https://github.com/login/oauth/access_token",
|
||||
upstream_client_id="client-B",
|
||||
upstream_client_secret="secret-B",
|
||||
token_verifier=jwt_verifier,
|
||||
base_url="https://myserver.com",
|
||||
client_storage=MemoryStore(), # Different storage instance
|
||||
)
|
||||
|
||||
# Verify that client-B keys were stored with different namespace
|
||||
set_calls = mock_keyring.set_password.call_args_list
|
||||
for call in set_calls:
|
||||
assert call[0][0] == "fastmcp"
|
||||
assert "client-B" in call[0][1] # Namespace includes client-B
|
||||
assert "client-A" not in call[0][1] # Not client-A
|
||||
|
||||
@patch("fastmcp.utilities.key_management.platform.system")
|
||||
@patch("fastmcp.utilities.key_management.keyring")
|
||||
async def test_keyring_retrieves_existing_keys(
|
||||
self, mock_keyring, mock_platform, jwt_verifier, memory_storage
|
||||
):
|
||||
"""Test that existing keyring keys are retrieved and reused."""
|
||||
mock_platform.return_value = "Darwin"
|
||||
|
||||
# Mock existing keys in keyring
|
||||
def get_password_side_effect(service, key):
|
||||
if "jwt-signing" in key:
|
||||
return "existing-jwt-key-base64"
|
||||
elif "token-encryption" in key:
|
||||
return "existing-encryption-key-base64"
|
||||
return None
|
||||
|
||||
mock_keyring.get_password.side_effect = get_password_side_effect
|
||||
|
||||
# Create proxy - should retrieve existing keys
|
||||
proxy = OAuthProxy(
|
||||
upstream_authorization_endpoint="https://github.com/login/oauth/authorize",
|
||||
upstream_token_endpoint="https://github.com/login/oauth/access_token",
|
||||
upstream_client_id="existing-keys-client",
|
||||
upstream_client_secret="test-secret",
|
||||
token_verifier=jwt_verifier,
|
||||
base_url="https://myserver.com",
|
||||
client_storage=memory_storage,
|
||||
)
|
||||
|
||||
# Trigger JWT initialization
|
||||
await proxy._ensure_jwt_initialized()
|
||||
|
||||
# Should retrieve but not set new keys
|
||||
assert mock_keyring.get_password.call_count == 2
|
||||
mock_keyring.set_password.assert_not_called()
|
||||
|
||||
@patch("fastmcp.utilities.key_management.platform.system")
|
||||
@patch("fastmcp.utilities.key_management.keyring")
|
||||
async def test_keyring_failure_uses_ephemeral_keys(
|
||||
self, mock_keyring, mock_platform, jwt_verifier, memory_storage
|
||||
):
|
||||
"""Test graceful fallback to ephemeral keys when keyring fails."""
|
||||
mock_platform.return_value = "Darwin"
|
||||
|
||||
# Simulate keyring failure
|
||||
mock_keyring.get_password.side_effect = Exception("Keyring backend unavailable")
|
||||
|
||||
# Should not raise - should fall back to ephemeral keys
|
||||
proxy = OAuthProxy(
|
||||
upstream_authorization_endpoint="https://github.com/login/oauth/authorize",
|
||||
upstream_token_endpoint="https://github.com/login/oauth/access_token",
|
||||
upstream_client_id="fallback-client",
|
||||
upstream_client_secret="test-secret",
|
||||
token_verifier=jwt_verifier,
|
||||
base_url="https://myserver.com",
|
||||
client_storage=memory_storage,
|
||||
)
|
||||
|
||||
# Proxy should be created successfully despite keyring failure
|
||||
assert proxy is not None
|
||||
|
|
|
|||
122
uv.lock
generated
122
uv.lock
generated
|
|
@ -69,6 +69,15 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "backports-tarfile"
|
||||
version = "1.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/86/72/cd9b395f25e290e633655a100af28cb253e4393396264a98bd5f5951d50f/backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991", size = 86406, upload-time = "2024-05-28T17:01:54.731Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181, upload-time = "2024-05-28T17:01:53.112Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "beartype"
|
||||
version = "0.22.2"
|
||||
|
|
@ -554,7 +563,7 @@ dependencies = [
|
|||
{ name = "openapi-core" },
|
||||
{ name = "openapi-pydantic" },
|
||||
{ name = "platformdirs" },
|
||||
{ name = "py-key-value-aio", extra = ["disk", "memory"] },
|
||||
{ name = "py-key-value-aio", extra = ["disk", "keyring", "memory"] },
|
||||
{ name = "pydantic", extra = ["email"] },
|
||||
{ name = "pyperclip" },
|
||||
{ name = "pytest-asyncio" },
|
||||
|
|
@ -605,7 +614,7 @@ requires-dist = [
|
|||
{ name = "openapi-core", specifier = ">=0.19.5" },
|
||||
{ name = "openapi-pydantic", specifier = ">=0.5.1" },
|
||||
{ name = "platformdirs", specifier = ">=4.0.0" },
|
||||
{ name = "py-key-value-aio", extras = ["disk", "memory"], specifier = ">=0.2.6,<0.3.0" },
|
||||
{ name = "py-key-value-aio", extras = ["disk", "keyring", "memory"], specifier = ">=0.2.6,<0.3.0" },
|
||||
{ name = "pydantic", extras = ["email"], specifier = ">=2.11.7" },
|
||||
{ name = "pyperclip", specifier = ">=1.9.0" },
|
||||
{ name = "pytest-asyncio", specifier = ">=1.2.0" },
|
||||
|
|
@ -713,6 +722,18 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "importlib-metadata"
|
||||
version = "8.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "zipp" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size = 56641, upload-time = "2025-04-27T15:29:01.736Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd", size = 27656, upload-time = "2025-04-27T15:29:00.214Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.1.0"
|
||||
|
|
@ -814,6 +835,42 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/15/aa/0aca39a37d3c7eb941ba736ede56d689e7be91cab5d9ca846bde3999eba6/isodate-0.7.2-py3-none-any.whl", hash = "sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15", size = 22320, upload-time = "2024-10-08T23:04:09.501Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jaraco-classes"
|
||||
version = "3.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "more-itertools" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jaraco-context"
|
||||
version = "6.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "backports-tarfile", marker = "python_full_version < '3.12'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/df/ad/f3777b81bf0b6e7bc7514a1656d3e637b2e8e15fab2ce3235730b3e7a4e6/jaraco_context-6.0.1.tar.gz", hash = "sha256:9bae4ea555cf0b14938dc0aee7c9f32ed303aa20a3b73e7dc80111628792d1b3", size = 13912, upload-time = "2024-08-20T03:39:27.358Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/db/0c52c4cf5e4bd9f5d7135ec7669a3a767af21b3a308e1ed3674881e52b62/jaraco.context-6.0.1-py3-none-any.whl", hash = "sha256:f797fc481b490edb305122c9181830a3a5b76d84ef6d1aef2fb9b47ab956f9e4", size = 6825, upload-time = "2024-08-20T03:39:25.966Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jaraco-functools"
|
||||
version = "4.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "more-itertools" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f7/ed/1aa2d585304ec07262e1a83a9889880701079dde796ac7b1d1826f40c63d/jaraco_functools-4.3.0.tar.gz", hash = "sha256:cfd13ad0dd2c47a3600b439ef72d8615d482cedcff1632930d6f28924d92f294", size = 19755, upload-time = "2025-08-18T20:05:09.91Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/09/726f168acad366b11e420df31bf1c702a54d373a83f968d94141a8c3fde0/jaraco_functools-4.3.0-py3-none-any.whl", hash = "sha256:227ff8ed6f7b8f62c56deff101545fa7543cf2c8e7b82a7c2116e672f29c26e8", size = 10408, upload-time = "2025-08-18T20:05:08.69Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jedi"
|
||||
version = "0.19.2"
|
||||
|
|
@ -826,6 +883,15 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278, upload-time = "2024-11-11T01:41:40.175Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jeepney"
|
||||
version = "0.9.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jiter"
|
||||
version = "0.10.0"
|
||||
|
|
@ -940,6 +1006,24 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/01/0e/b27cdbaccf30b890c40ed1da9fd4a3593a5cf94dae54fb34f8a4b74fcd3f/jsonschema_specifications-2025.4.1-py3-none-any.whl", hash = "sha256:4653bffbd6584f7de83a67e0d620ef16900b390ddc7939d56684d6c81e33f1af", size = 18437, upload-time = "2025-04-23T12:34:05.422Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "keyring"
|
||||
version = "25.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "importlib-metadata", marker = "python_full_version < '3.12'" },
|
||||
{ name = "jaraco-classes" },
|
||||
{ name = "jaraco-context" },
|
||||
{ name = "jaraco-functools" },
|
||||
{ name = "jeepney", marker = "sys_platform == 'linux'" },
|
||||
{ name = "pywin32-ctypes", marker = "sys_platform == 'win32'" },
|
||||
{ name = "secretstorage", marker = "sys_platform == 'linux'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/70/09/d904a6e96f76ff214be59e7aa6ef7190008f52a0ab6689760a98de0bf37d/keyring-25.6.0.tar.gz", hash = "sha256:0b39998aa941431eb3d9b0d4b2460bc773b9df6fed7621c2dfb291a7e0187a66", size = 62750, upload-time = "2024-12-25T15:26:45.782Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/32/da7f44bcb1105d3e88a0b74ebdca50c59121d2ddf71c9e34ba47df7f3a56/keyring-25.6.0-py3-none-any.whl", hash = "sha256:552a3f7af126ece7ed5c89753650eec89c7eaae8617d0aa4d9ad2b75111266bd", size = 39085, upload-time = "2024-12-25T15:26:44.377Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy-object-proxy"
|
||||
version = "1.11.0"
|
||||
|
|
@ -1337,6 +1421,9 @@ disk = [
|
|||
{ name = "diskcache" },
|
||||
{ name = "pathvalidate" },
|
||||
]
|
||||
keyring = [
|
||||
{ name = "keyring" },
|
||||
]
|
||||
memory = [
|
||||
{ name = "cachetools" },
|
||||
]
|
||||
|
|
@ -1743,6 +1830,15 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pywin32-ctypes"
|
||||
version = "0.2.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyyaml"
|
||||
version = "6.0.2"
|
||||
|
|
@ -2014,6 +2110,19 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/cb/5c/799a1efb8b5abab56e8a9f2a0b72d12bd64bb55815e9476c7d0a2887d2f7/ruff-0.12.8-py3-none-win_arm64.whl", hash = "sha256:c90e1a334683ce41b0e7a04f41790c429bf5073b62c1ae701c9dc5b3d14f0749", size = 11884718, upload-time = "2025-08-07T19:05:42.866Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "secretstorage"
|
||||
version = "3.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cryptography" },
|
||||
{ name = "jeepney" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/31/9f/11ef35cf1027c1339552ea7bfe6aaa74a8516d8b5caf6e7d338daf54fd80/secretstorage-3.4.0.tar.gz", hash = "sha256:c46e216d6815aff8a8a18706a2fbfd8d53fcbb0dce99301881687a1b0289ef7c", size = 19748, upload-time = "2025-09-09T16:42:13.859Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/91/ff/2e2eed29e02c14a5cb6c57f09b2d5b40e65d6cc71f45b52e0be295ccbc2f/secretstorage-3.4.0-py3-none-any.whl", hash = "sha256:0e3b6265c2c63509fb7415717607e4b2c9ab767b7f344a57473b779ca13bd02e", size = 15272, upload-time = "2025-09-09T16:42:12.744Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "six"
|
||||
version = "1.17.0"
|
||||
|
|
@ -2293,3 +2402,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/32/af/d4502dc713b4ccea7
|
|||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/ea/c67e1dee1ba208ed22c06d1d547ae5e293374bfc43e0eb0ef5e262b68561/werkzeug-3.1.1-py3-none-any.whl", hash = "sha256:a71124d1ef06008baafa3d266c02f56e1836a5984afd6dd6c9230669d60d9fb5", size = 224371, upload-time = "2024-11-01T16:40:43.994Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zipp"
|
||||
version = "3.23.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" },
|
||||
]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue