Jeremiah Lowin commited on
Commit
e29e8a6
·
unverified ·
2 Parent(s): 0fcdf08058fad1

Merge pull request #652 from jlowin/bearer-env

Browse files
src/fastmcp/server/auth/auth.py CHANGED
@@ -5,7 +5,6 @@ from mcp.server.auth.provider import (
5
  RefreshToken,
6
  )
7
  from mcp.server.auth.settings import (
8
- AuthSettings,
9
  ClientRegistrationOptions,
10
  RevocationOptions,
11
  )
@@ -39,10 +38,8 @@ class OAuthProvider(
39
  if isinstance(service_documentation_url, str):
40
  service_documentation_url = AnyHttpUrl(service_documentation_url)
41
 
42
- self.settings = AuthSettings(
43
- issuer_url=issuer_url,
44
- service_documentation_url=service_documentation_url,
45
- client_registration_options=client_registration_options,
46
- revocation_options=revocation_options,
47
- required_scopes=required_scopes,
48
- )
 
5
  RefreshToken,
6
  )
7
  from mcp.server.auth.settings import (
 
8
  ClientRegistrationOptions,
9
  RevocationOptions,
10
  )
 
38
  if isinstance(service_documentation_url, str):
39
  service_documentation_url = AnyHttpUrl(service_documentation_url)
40
 
41
+ self.issuer_url = issuer_url
42
+ self.service_documentation_url = service_documentation_url
43
+ self.client_registration_options = client_registration_options
44
+ self.revocation_options = revocation_options
45
+ self.required_scopes = required_scopes
 
 
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")
@@ -315,7 +295,8 @@ class BearerAuthProvider(OAuthProvider):
315
  if exp and exp < time.time():
316
  return None
317
 
318
- # Validate issuer
 
319
  if self.issuer:
320
  if claims.get("iss") != self.issuer:
321
  return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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")
 
295
  if exp and exp < time.time():
296
  return None
297
 
298
+ # Validate issuer - note we use issuer instead of issuer_url here because
299
+ # issuer is optional, allowing users to make this check optional
300
  if self.issuer:
301
  if claims.get("iss") != self.issuer:
302
  return None
src/fastmcp/server/auth/providers/bearer_env.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic_settings import BaseSettings, SettingsConfigDict
2
+
3
+ from fastmcp.server.auth.providers.bearer import BearerAuthProvider
4
+
5
+
6
+ # Sentinel object to indicate that a setting is not set
7
+ class _NotSet:
8
+ pass
9
+
10
+
11
+ class EnvBearerAuthProviderSettings(BaseSettings):
12
+ """Settings for the BearerAuthProvider."""
13
+
14
+ model_config = SettingsConfigDict(
15
+ env_prefix="FASTMCP_AUTH_BEARER_",
16
+ env_file=".env",
17
+ extra="ignore",
18
+ )
19
+
20
+ public_key: str | None = None
21
+ jwks_uri: str | None = None
22
+ issuer: str | None = None
23
+ audience: str | None = None
24
+ required_scopes: list[str] | None = None
25
+
26
+
27
+ class EnvBearerAuthProvider(BearerAuthProvider):
28
+ """
29
+ A BearerAuthProvider that loads settings from environment variables. Any
30
+ providing setting will always take precedence over the environment
31
+ variables.
32
+ """
33
+
34
+ def __init__(
35
+ self,
36
+ public_key: str | None | type[_NotSet] = _NotSet,
37
+ jwks_uri: str | None | type[_NotSet] = _NotSet,
38
+ issuer: str | None | type[_NotSet] = _NotSet,
39
+ audience: str | None | type[_NotSet] = _NotSet,
40
+ required_scopes: list[str] | None | type[_NotSet] = _NotSet,
41
+ ):
42
+ """
43
+ Initialize the provider.
44
+
45
+ Args:
46
+ public_key: RSA public key in PEM format (for static key)
47
+ jwks_uri: URI to fetch keys from (for key rotation)
48
+ issuer: Expected issuer claim (optional)
49
+ audience: Expected audience claim (optional)
50
+ required_scopes: List of required scopes for access (optional)
51
+ """
52
+ kwargs = {
53
+ "public_key": public_key,
54
+ "jwks_uri": jwks_uri,
55
+ "issuer": issuer,
56
+ "audience": audience,
57
+ "required_scopes": required_scopes,
58
+ }
59
+ settings = EnvBearerAuthProviderSettings(
60
+ **{k: v for k, v in kwargs.items() if v is not _NotSet}
61
+ )
62
+ 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.settings.required_scopes or []
95
 
96
  auth_routes.extend(
97
  create_auth_routes(
98
  provider=auth,
99
- issuer_url=auth.settings.issuer_url,
100
- service_documentation_url=auth.settings.service_documentation_url,
101
- client_registration_options=auth.settings.client_registration_options,
102
- revocation_options=auth.settings.revocation_options,
103
  )
104
  )
105
 
 
91
  Middleware(AuthContextMiddleware),
92
  ]
93
 
94
+ required_scopes = auth.required_scopes or []
95
 
96
  auth_routes.extend(
97
  create_auth_routes(
98
  provider=auth,
99
+ issuer_url=auth.issuer_url,
100
+ service_documentation_url=auth.service_documentation_url,
101
+ client_registration_options=auth.client_registration_options,
102
+ revocation_options=auth.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.default_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 BaseSettings, SettingsConfigDict
 
 
 
9
  from typing_extensions import Self
10
 
11
  LOG_LEVEL = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
@@ -176,5 +179,24 @@ 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
+ default_auth_provider: Annotated[
184
+ Literal["bearer_env"] | None,
185
+ Field(
186
+ description=inspect.cleandoc(
187
+ """
188
+ Configure the authentication provider. This setting is intended only to
189
+ be used for remote confirugation of providers that fully support
190
+ environment variable configuration.
191
+
192
+ If None, no automatic configuration will take place.
193
+
194
+ This setting is *always* overriden by any auth provider passed to the
195
+ FastMCP constructor.
196
+ """
197
+ ),
198
+ ),
199
+ ] = None
200
+
201
 
202
  settings = Settings()
tests/auth/providers/test_bearer_env.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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_DEFAULT_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_DEFAULT_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_DEFAULT_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_url == AnyHttpUrl("http://test-issuer")
45
+ assert mcp.auth.audience == "test-audience"
46
+ assert mcp.auth.required_scopes == ["test-scope1", "test-scope2"]
47
+
48
+
49
+ def test_list_of_scopes_must_be_a_list(monkeypatch):
50
+ monkeypatch.setenv("FASTMCP_SERVER_DEFAULT_AUTH_PROVIDER", "bearer_env")
51
+ monkeypatch.setenv("FASTMCP_AUTH_BEARER_REQUIRED_SCOPES", "test-scope1")
52
+
53
+ with pytest.raises(ValidationError, match="Input should be a valid list"):
54
+ FastMCP()
55
+
56
+
57
+ def test_configure_bearer_env_jwks_uri_from_env_var(monkeypatch):
58
+ monkeypatch.setenv("FASTMCP_SERVER_DEFAULT_AUTH_PROVIDER", "bearer_env")
59
+ monkeypatch.setenv("FASTMCP_AUTH_BEARER_JWKS_URI", "test-jwks-uri")
60
+
61
+ mcp = FastMCP()
62
+ assert isinstance(mcp.auth, EnvBearerAuthProvider)
63
+ assert mcp.auth.jwks_uri == "test-jwks-uri"
64
+
65
+
66
+ def test_configure_bearer_env_public_key_and_jwks_uri_error(monkeypatch):
67
+ monkeypatch.setenv("FASTMCP_SERVER_DEFAULT_AUTH_PROVIDER", "bearer_env")
68
+ monkeypatch.setenv("FASTMCP_AUTH_BEARER_PUBLIC_KEY", "test-public-key")
69
+ monkeypatch.setenv("FASTMCP_AUTH_BEARER_JWKS_URI", "test-jwks-uri")
70
+
71
+ with pytest.raises(ValueError, match="Provide either public_key or jwks_uri"):
72
+ FastMCP()
73
+
74
+
75
+ def test_provided_auth_takes_precedence_over_env_vars(monkeypatch):
76
+ monkeypatch.setenv("FASTMCP_SERVER_DEFAULT_AUTH_PROVIDER", "bearer_env")
77
+ monkeypatch.setenv("FASTMCP_AUTH_BEARER_PUBLIC_KEY", "test-public-key")
78
+
79
+ mcp = FastMCP(auth=BearerAuthProvider(public_key="test-public-key-2"))
80
+ assert isinstance(mcp.auth, BearerAuthProvider)
81
+ assert not isinstance(mcp.auth, EnvBearerAuthProvider)
82
+ assert mcp.auth.public_key == "test-public-key-2"