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.settings = AuthSettings(
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.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.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 BaseSettings, SettingsConfigDict
 
 
 
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"