Spaces:
Running
Running
Jeremiah Lowin commited on
Commit ·
b310c16
1
Parent(s): 0fcdf08
Support configuring bearer auth from env vars
Browse files
src/fastmcp/server/auth/auth.py
CHANGED
|
@@ -39,7 +39,7 @@ class OAuthProvider(
|
|
| 39 |
if isinstance(service_documentation_url, str):
|
| 40 |
service_documentation_url = AnyHttpUrl(service_documentation_url)
|
| 41 |
|
| 42 |
-
self.
|
| 43 |
issuer_url=issuer_url,
|
| 44 |
service_documentation_url=service_documentation_url,
|
| 45 |
client_registration_options=client_registration_options,
|
|
|
|
| 39 |
if isinstance(service_documentation_url, str):
|
| 40 |
service_documentation_url = AnyHttpUrl(service_documentation_url)
|
| 41 |
|
| 42 |
+
self.auth_settings = AuthSettings(
|
| 43 |
issuer_url=issuer_url,
|
| 44 |
service_documentation_url=service_documentation_url,
|
| 45 |
client_registration_options=client_registration_options,
|
src/fastmcp/server/auth/providers/bearer.py
CHANGED
|
@@ -1,25 +1,3 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Simple JWT Bearer Token validation for hosted MCP servers.
|
| 3 |
-
|
| 4 |
-
Uses RS256 (asymmetric) where your control plane signs with a private key
|
| 5 |
-
and hosted MCP servers validate with the corresponding public key.
|
| 6 |
-
|
| 7 |
-
Example usage:
|
| 8 |
-
# Static public key
|
| 9 |
-
provider = BearerAuthProvider(
|
| 10 |
-
public_key='''-----BEGIN PUBLIC KEY-----
|
| 11 |
-
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...
|
| 12 |
-
-----END PUBLIC KEY-----''',
|
| 13 |
-
issuer="https://auth.yourservice.com"
|
| 14 |
-
)
|
| 15 |
-
|
| 16 |
-
# Or JWKS URI (recommended for production - allows key rotation)
|
| 17 |
-
provider = Bear(
|
| 18 |
-
jwks_uri="https://auth.yourservice.com/.well-known/jwks.json",
|
| 19 |
-
issuer="https://auth.yourservice.com"
|
| 20 |
-
)
|
| 21 |
-
"""
|
| 22 |
-
|
| 23 |
import time
|
| 24 |
from dataclasses import dataclass
|
| 25 |
from typing import Any, TypedDict
|
|
@@ -165,7 +143,6 @@ class RSAKeyPair:
|
|
| 165 |
payload,
|
| 166 |
key=self.private_key.get_secret_value(),
|
| 167 |
)
|
| 168 |
-
|
| 169 |
return token_bytes.decode("utf-8")
|
| 170 |
|
| 171 |
|
|
@@ -174,25 +151,28 @@ class BearerAuthProvider(OAuthProvider):
|
|
| 174 |
Simple JWT Bearer Token validator for hosted MCP servers.
|
| 175 |
Uses RS256 asymmetric encryption. Supports either static public key
|
| 176 |
or JWKS URI for key rotation.
|
|
|
|
|
|
|
|
|
|
| 177 |
"""
|
| 178 |
|
| 179 |
def __init__(
|
| 180 |
self,
|
| 181 |
-
issuer: str | None = None,
|
| 182 |
public_key: str | None = None,
|
| 183 |
jwks_uri: str | None = None,
|
|
|
|
| 184 |
audience: str | None = None,
|
| 185 |
required_scopes: list[str] | None = None,
|
| 186 |
):
|
| 187 |
"""
|
| 188 |
-
Initialize the provider.
|
| 189 |
|
| 190 |
Args:
|
| 191 |
-
issuer: Expected issuer claim (your control plane)
|
| 192 |
public_key: RSA public key in PEM format (for static key)
|
| 193 |
jwks_uri: URI to fetch keys from (for key rotation)
|
|
|
|
| 194 |
audience: Expected audience claim (optional)
|
| 195 |
-
required_scopes: List of required scopes for access
|
| 196 |
"""
|
| 197 |
if not (public_key or jwks_uri):
|
| 198 |
raise ValueError("Either public_key or jwks_uri must be provided")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import time
|
| 2 |
from dataclasses import dataclass
|
| 3 |
from typing import Any, TypedDict
|
|
|
|
| 143 |
payload,
|
| 144 |
key=self.private_key.get_secret_value(),
|
| 145 |
)
|
|
|
|
| 146 |
return token_bytes.decode("utf-8")
|
| 147 |
|
| 148 |
|
|
|
|
| 151 |
Simple JWT Bearer Token validator for hosted MCP servers.
|
| 152 |
Uses RS256 asymmetric encryption. Supports either static public key
|
| 153 |
or JWKS URI for key rotation.
|
| 154 |
+
|
| 155 |
+
Note that this provider DOES NOT permit client registration or revocation, or any OAuth flows.
|
| 156 |
+
It is intended to be used with a control plane that manages clients and tokens.
|
| 157 |
"""
|
| 158 |
|
| 159 |
def __init__(
|
| 160 |
self,
|
|
|
|
| 161 |
public_key: str | None = None,
|
| 162 |
jwks_uri: str | None = None,
|
| 163 |
+
issuer: str | None = None,
|
| 164 |
audience: str | None = None,
|
| 165 |
required_scopes: list[str] | None = None,
|
| 166 |
):
|
| 167 |
"""
|
| 168 |
+
Initialize the provider. Either public_key or jwks_uri must be provided.
|
| 169 |
|
| 170 |
Args:
|
|
|
|
| 171 |
public_key: RSA public key in PEM format (for static key)
|
| 172 |
jwks_uri: URI to fetch keys from (for key rotation)
|
| 173 |
+
issuer: Expected issuer claim (optional)
|
| 174 |
audience: Expected audience claim (optional)
|
| 175 |
+
required_scopes: List of required scopes for access (optional)
|
| 176 |
"""
|
| 177 |
if not (public_key or jwks_uri):
|
| 178 |
raise ValueError("Either public_key or jwks_uri must be provided")
|
src/fastmcp/server/auth/providers/bearer_env.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from enum import Enum
|
| 2 |
+
|
| 3 |
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 4 |
+
|
| 5 |
+
from fastmcp.server.auth.providers.bearer import BearerAuthProvider
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class NotSet(Enum):
|
| 9 |
+
sentinel = 0
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
NOTSET = NotSet.sentinel
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class EnvBearerAuthProviderSettings(BaseSettings):
|
| 16 |
+
"""Settings for the BearerAuthProvider."""
|
| 17 |
+
|
| 18 |
+
model_config = SettingsConfigDict(
|
| 19 |
+
env_prefix="FASTMCP_AUTH_BEARER_",
|
| 20 |
+
env_file=".env",
|
| 21 |
+
extra="ignore",
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
public_key: str | None = None
|
| 25 |
+
jwks_uri: str | None = None
|
| 26 |
+
issuer: str | None = None
|
| 27 |
+
audience: str | None = None
|
| 28 |
+
required_scopes: list[str] | None = None
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class EnvBearerAuthProvider(BearerAuthProvider):
|
| 32 |
+
"""
|
| 33 |
+
A BearerAuthProvider that loads settings from environment variables.
|
| 34 |
+
"""
|
| 35 |
+
|
| 36 |
+
def __init__(
|
| 37 |
+
self,
|
| 38 |
+
public_key: str | None | NotSet = NOTSET,
|
| 39 |
+
jwks_uri: str | None | NotSet = NOTSET,
|
| 40 |
+
issuer: str | None | NotSet = NOTSET,
|
| 41 |
+
audience: str | None | NotSet = NOTSET,
|
| 42 |
+
required_scopes: list[str] | None | NotSet = NOTSET,
|
| 43 |
+
):
|
| 44 |
+
kwargs = {
|
| 45 |
+
"public_key": public_key,
|
| 46 |
+
"jwks_uri": jwks_uri,
|
| 47 |
+
"issuer": issuer,
|
| 48 |
+
"audience": audience,
|
| 49 |
+
"required_scopes": required_scopes,
|
| 50 |
+
}
|
| 51 |
+
settings = EnvBearerAuthProviderSettings(
|
| 52 |
+
**{k: v for k, v in kwargs.items() if v is not NOTSET}
|
| 53 |
+
)
|
| 54 |
+
super().__init__(**settings.model_dump())
|
src/fastmcp/server/http.py
CHANGED
|
@@ -91,15 +91,15 @@ def setup_auth_middleware_and_routes(
|
|
| 91 |
Middleware(AuthContextMiddleware),
|
| 92 |
]
|
| 93 |
|
| 94 |
-
required_scopes = auth.
|
| 95 |
|
| 96 |
auth_routes.extend(
|
| 97 |
create_auth_routes(
|
| 98 |
provider=auth,
|
| 99 |
-
issuer_url=auth.
|
| 100 |
-
service_documentation_url=auth.
|
| 101 |
-
client_registration_options=auth.
|
| 102 |
-
revocation_options=auth.
|
| 103 |
)
|
| 104 |
)
|
| 105 |
|
|
|
|
| 91 |
Middleware(AuthContextMiddleware),
|
| 92 |
]
|
| 93 |
|
| 94 |
+
required_scopes = auth.auth_settings.required_scopes or []
|
| 95 |
|
| 96 |
auth_routes.extend(
|
| 97 |
create_auth_routes(
|
| 98 |
provider=auth,
|
| 99 |
+
issuer_url=auth.auth_settings.issuer_url,
|
| 100 |
+
service_documentation_url=auth.auth_settings.service_documentation_url,
|
| 101 |
+
client_registration_options=auth.auth_settings.client_registration_options,
|
| 102 |
+
revocation_options=auth.auth_settings.revocation_options,
|
| 103 |
)
|
| 104 |
)
|
| 105 |
|
src/fastmcp/server/server.py
CHANGED
|
@@ -48,6 +48,7 @@ from fastmcp.prompts.prompt import PromptResult
|
|
| 48 |
from fastmcp.resources import Resource, ResourceManager
|
| 49 |
from fastmcp.resources.template import ResourceTemplate
|
| 50 |
from fastmcp.server.auth.auth import OAuthProvider
|
|
|
|
| 51 |
from fastmcp.server.http import (
|
| 52 |
StarletteWithLifespan,
|
| 53 |
create_sse_app,
|
|
@@ -186,6 +187,8 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 186 |
lifespan=_lifespan_wrapper(self, lifespan),
|
| 187 |
)
|
| 188 |
|
|
|
|
|
|
|
| 189 |
self.auth = auth
|
| 190 |
|
| 191 |
if tools:
|
|
|
|
| 48 |
from fastmcp.resources import Resource, ResourceManager
|
| 49 |
from fastmcp.resources.template import ResourceTemplate
|
| 50 |
from fastmcp.server.auth.auth import OAuthProvider
|
| 51 |
+
from fastmcp.server.auth.providers.bearer_env import EnvBearerAuthProvider
|
| 52 |
from fastmcp.server.http import (
|
| 53 |
StarletteWithLifespan,
|
| 54 |
create_sse_app,
|
|
|
|
| 187 |
lifespan=_lifespan_wrapper(self, lifespan),
|
| 188 |
)
|
| 189 |
|
| 190 |
+
if auth is None and self.settings.auth_provider == "bearer_env":
|
| 191 |
+
auth = EnvBearerAuthProvider()
|
| 192 |
self.auth = auth
|
| 193 |
|
| 194 |
if tools:
|
src/fastmcp/settings.py
CHANGED
|
@@ -5,7 +5,10 @@ from pathlib import Path
|
|
| 5 |
from typing import Annotated, Literal
|
| 6 |
|
| 7 |
from pydantic import Field, model_validator
|
| 8 |
-
from pydantic_settings import
|
|
|
|
|
|
|
|
|
|
| 9 |
from typing_extensions import Self
|
| 10 |
|
| 11 |
LOG_LEVEL = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
|
@@ -176,5 +179,8 @@ class ServerSettings(BaseSettings):
|
|
| 176 |
False # If True, uses true stateless mode (new transport per request)
|
| 177 |
)
|
| 178 |
|
|
|
|
|
|
|
|
|
|
| 179 |
|
| 180 |
settings = Settings()
|
|
|
|
| 5 |
from typing import Annotated, Literal
|
| 6 |
|
| 7 |
from pydantic import Field, model_validator
|
| 8 |
+
from pydantic_settings import (
|
| 9 |
+
BaseSettings,
|
| 10 |
+
SettingsConfigDict,
|
| 11 |
+
)
|
| 12 |
from typing_extensions import Self
|
| 13 |
|
| 14 |
LOG_LEVEL = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
|
|
|
| 179 |
False # If True, uses true stateless mode (new transport per request)
|
| 180 |
)
|
| 181 |
|
| 182 |
+
# Auth settings
|
| 183 |
+
auth_provider: Literal["bearer_env"] | None = None
|
| 184 |
+
|
| 185 |
|
| 186 |
settings = Settings()
|
tests/auth/providers/test_bearer_env.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
from pydantic import AnyHttpUrl, ValidationError
|
| 3 |
+
|
| 4 |
+
from fastmcp import FastMCP
|
| 5 |
+
from fastmcp.server.auth.providers.bearer import BearerAuthProvider
|
| 6 |
+
from fastmcp.server.auth.providers.bearer_env import EnvBearerAuthProvider
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def test_load_bearer_env_from_env_var(monkeypatch):
|
| 10 |
+
mcp = FastMCP()
|
| 11 |
+
assert mcp.auth is None
|
| 12 |
+
|
| 13 |
+
monkeypatch.setenv("FASTMCP_SERVER_AUTH_PROVIDER", "bearer_env")
|
| 14 |
+
monkeypatch.setenv("FASTMCP_AUTH_BEARER_PUBLIC_KEY", "test-public-key")
|
| 15 |
+
|
| 16 |
+
mcp_with_auth = FastMCP()
|
| 17 |
+
assert isinstance(mcp_with_auth.auth, EnvBearerAuthProvider)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def test_load_bearer_env_from_env_var_requires_public_key_or_jwks_uri(monkeypatch):
|
| 21 |
+
mcp = FastMCP()
|
| 22 |
+
assert mcp.auth is None
|
| 23 |
+
|
| 24 |
+
monkeypatch.setenv("FASTMCP_SERVER_AUTH_PROVIDER", "bearer_env")
|
| 25 |
+
|
| 26 |
+
with pytest.raises(
|
| 27 |
+
ValueError, match="Either public_key or jwks_uri must be provided"
|
| 28 |
+
):
|
| 29 |
+
FastMCP()
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def test_configure_bearer_env_from_env_var(monkeypatch):
|
| 33 |
+
monkeypatch.setenv("FASTMCP_SERVER_AUTH_PROVIDER", "bearer_env")
|
| 34 |
+
monkeypatch.setenv("FASTMCP_AUTH_BEARER_PUBLIC_KEY", "test-public-key")
|
| 35 |
+
monkeypatch.setenv("FASTMCP_AUTH_BEARER_ISSUER", "http://test-issuer")
|
| 36 |
+
monkeypatch.setenv("FASTMCP_AUTH_BEARER_AUDIENCE", "test-audience")
|
| 37 |
+
monkeypatch.setenv(
|
| 38 |
+
"FASTMCP_AUTH_BEARER_REQUIRED_SCOPES", '["test-scope1", "test-scope2"]'
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
mcp = FastMCP()
|
| 42 |
+
assert isinstance(mcp.auth, EnvBearerAuthProvider)
|
| 43 |
+
assert mcp.auth.public_key == "test-public-key"
|
| 44 |
+
assert mcp.auth.issuer == "http://test-issuer"
|
| 45 |
+
assert mcp.auth.auth_settings.issuer_url == AnyHttpUrl("http://test-issuer")
|
| 46 |
+
assert mcp.auth.audience == "test-audience"
|
| 47 |
+
assert mcp.auth.auth_settings.required_scopes == ["test-scope1", "test-scope2"]
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def test_list_of_scopes_must_be_a_list(monkeypatch):
|
| 51 |
+
monkeypatch.setenv("FASTMCP_SERVER_AUTH_PROVIDER", "bearer_env")
|
| 52 |
+
monkeypatch.setenv("FASTMCP_AUTH_BEARER_REQUIRED_SCOPES", "test-scope1")
|
| 53 |
+
|
| 54 |
+
with pytest.raises(ValidationError, match="Input should be a valid list"):
|
| 55 |
+
FastMCP()
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def test_configure_bearer_env_jwks_uri_from_env_var(monkeypatch):
|
| 59 |
+
monkeypatch.setenv("FASTMCP_SERVER_AUTH_PROVIDER", "bearer_env")
|
| 60 |
+
monkeypatch.setenv("FASTMCP_AUTH_BEARER_JWKS_URI", "test-jwks-uri")
|
| 61 |
+
|
| 62 |
+
mcp = FastMCP()
|
| 63 |
+
assert isinstance(mcp.auth, EnvBearerAuthProvider)
|
| 64 |
+
assert mcp.auth.jwks_uri == "test-jwks-uri"
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def test_configure_bearer_env_public_key_and_jwks_uri_error(monkeypatch):
|
| 68 |
+
monkeypatch.setenv("FASTMCP_SERVER_AUTH_PROVIDER", "bearer_env")
|
| 69 |
+
monkeypatch.setenv("FASTMCP_AUTH_BEARER_PUBLIC_KEY", "test-public-key")
|
| 70 |
+
monkeypatch.setenv("FASTMCP_AUTH_BEARER_JWKS_URI", "test-jwks-uri")
|
| 71 |
+
|
| 72 |
+
with pytest.raises(ValueError, match="Provide either public_key or jwks_uri"):
|
| 73 |
+
FastMCP()
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def test_provided_auth_takes_precedence_over_env_vars(monkeypatch):
|
| 77 |
+
monkeypatch.setenv("FASTMCP_SERVER_AUTH_PROVIDER", "bearer_env")
|
| 78 |
+
monkeypatch.setenv("FASTMCP_AUTH_BEARER_PUBLIC_KEY", "test-public-key")
|
| 79 |
+
|
| 80 |
+
mcp = FastMCP(auth=BearerAuthProvider(public_key="test-public-key-2"))
|
| 81 |
+
assert isinstance(mcp.auth, BearerAuthProvider)
|
| 82 |
+
assert not isinstance(mcp.auth, EnvBearerAuthProvider)
|
| 83 |
+
assert mcp.auth.public_key == "test-public-key-2"
|