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:
Jeremiah Lowin 2025-10-22 20:42:24 -04:00 committed by GitHub
commit 686082a5b5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 669 additions and 100 deletions

View file

@ -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

View file

@ -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

View file

@ -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" />

View file

@ -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>

View file

@ -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

View file

@ -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",
]

View file

@ -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

View 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

View file

@ -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]

View file

@ -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
View file

@ -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" },
]