dacamposol commited on
Commit
9a6f944
·
unverified ·
1 Parent(s): 3257d51

feat: Allow Resource Metadata URL as field in OAuthProvider (#1287)

Browse files
src/fastmcp/server/auth/auth.py CHANGED
@@ -21,6 +21,7 @@ class OAuthProvider(
21
  client_registration_options: ClientRegistrationOptions | None = None,
22
  revocation_options: RevocationOptions | None = None,
23
  required_scopes: list[str] | None = None,
 
24
  ):
25
  """
26
  Initialize the OAuth provider.
@@ -43,6 +44,9 @@ class OAuthProvider(
43
  self.client_registration_options = client_registration_options
44
  self.revocation_options = revocation_options
45
  self.required_scopes = required_scopes
 
 
 
46
 
47
  async def verify_token(self, token: str) -> AccessToken | None:
48
  """
 
21
  client_registration_options: ClientRegistrationOptions | None = None,
22
  revocation_options: RevocationOptions | None = None,
23
  required_scopes: list[str] | None = None,
24
+ resource_server_url: AnyHttpUrl | str | None = None,
25
  ):
26
  """
27
  Initialize the OAuth provider.
 
44
  self.client_registration_options = client_registration_options
45
  self.revocation_options = revocation_options
46
  self.required_scopes = required_scopes
47
+ self.resource_server_url = (
48
+ AnyHttpUrl(resource_server_url) if resource_server_url else None
49
+ )
50
 
51
  async def verify_token(self, token: str) -> AccessToken | None:
52
  """
src/fastmcp/server/auth/providers/bearer.py CHANGED
@@ -167,6 +167,7 @@ class BearerAuthProvider(OAuthProvider):
167
  algorithm: str | None = None,
168
  audience: str | list[str] | None = None,
169
  required_scopes: list[str] | None = None,
 
170
  ):
171
  """
172
  Initialize the provider. Either public_key or jwks_uri must be provided.
@@ -210,11 +211,19 @@ class BearerAuthProvider(OAuthProvider):
210
  # Issuer is not a valid URL, use default for parent class
211
  issuer_url = "https://fastmcp.example.com"
212
 
 
 
 
 
 
 
 
213
  super().__init__(
214
  issuer_url=issuer_url,
215
  client_registration_options=ClientRegistrationOptions(enabled=False),
216
  revocation_options=RevocationOptions(enabled=False),
217
  required_scopes=required_scopes,
 
218
  )
219
 
220
  self.algorithm = algorithm
 
167
  algorithm: str | None = None,
168
  audience: str | list[str] | None = None,
169
  required_scopes: list[str] | None = None,
170
+ resource_server: str | None = None,
171
  ):
172
  """
173
  Initialize the provider. Either public_key or jwks_uri must be provided.
 
211
  # Issuer is not a valid URL, use default for parent class
212
  issuer_url = "https://fastmcp.example.com"
213
 
214
+ try:
215
+ resource_server_url = (
216
+ AnyHttpUrl(resource_server) if resource_server else None
217
+ )
218
+ except ValidationError:
219
+ resource_server_url = None
220
+
221
  super().__init__(
222
  issuer_url=issuer_url,
223
  client_registration_options=ClientRegistrationOptions(enabled=False),
224
  revocation_options=RevocationOptions(enabled=False),
225
  required_scopes=required_scopes,
226
+ resource_server_url=resource_server_url,
227
  )
228
 
229
  self.algorithm = algorithm
src/fastmcp/server/auth/providers/bearer_env.py CHANGED
@@ -37,6 +37,7 @@ class EnvBearerAuthProvider(BearerAuthProvider):
37
  algorithm: str | None | EllipsisType = ...,
38
  audience: str | None | EllipsisType = ...,
39
  required_scopes: list[str] | None | EllipsisType = ...,
 
40
  ):
41
  """
42
  Initialize the provider.
@@ -56,6 +57,7 @@ class EnvBearerAuthProvider(BearerAuthProvider):
56
  "algorithm": algorithm,
57
  "audience": audience,
58
  "required_scopes": required_scopes,
 
59
  }
60
  settings = EnvBearerAuthProviderSettings(
61
  **{k: v for k, v in kwargs.items() if v is not ...}
 
37
  algorithm: str | None | EllipsisType = ...,
38
  audience: str | None | EllipsisType = ...,
39
  required_scopes: list[str] | None | EllipsisType = ...,
40
+ resource_server: str | None | EllipsisType = ...,
41
  ):
42
  """
43
  Initialize the provider.
 
57
  "algorithm": algorithm,
58
  "audience": audience,
59
  "required_scopes": required_scopes,
60
+ "resource_server": resource_server,
61
  }
62
  settings = EnvBearerAuthProviderSettings(
63
  **{k: v for k, v in kwargs.items() if v is not ...}
src/fastmcp/server/auth/providers/in_memory.py CHANGED
@@ -41,6 +41,7 @@ class InMemoryOAuthProvider(OAuthProvider):
41
  client_registration_options: ClientRegistrationOptions | None = None,
42
  revocation_options: RevocationOptions | None = None,
43
  required_scopes: list[str] | None = None,
 
44
  ):
45
  super().__init__(
46
  issuer_url=issuer_url or "http://fastmcp.example.com",
@@ -48,6 +49,7 @@ class InMemoryOAuthProvider(OAuthProvider):
48
  client_registration_options=client_registration_options,
49
  revocation_options=revocation_options,
50
  required_scopes=required_scopes,
 
51
  )
52
  self.clients: dict[str, OAuthClientInformationFull] = {}
53
  self.auth_codes: dict[str, AuthorizationCode] = {}
 
41
  client_registration_options: ClientRegistrationOptions | None = None,
42
  revocation_options: RevocationOptions | None = None,
43
  required_scopes: list[str] | None = None,
44
+ resource_server_url: AnyHttpUrl | str | None = None,
45
  ):
46
  super().__init__(
47
  issuer_url=issuer_url or "http://fastmcp.example.com",
 
49
  client_registration_options=client_registration_options,
50
  revocation_options=revocation_options,
51
  required_scopes=required_scopes,
52
+ resource_server_url=resource_server_url,
53
  )
54
  self.clients: dict[str, OAuthClientInformationFull] = {}
55
  self.auth_codes: dict[str, AuthorizationCode] = {}
src/fastmcp/server/http.py CHANGED
@@ -15,6 +15,7 @@ from mcp.server.lowlevel.server import LifespanResultT
15
  from mcp.server.sse import SseServerTransport
16
  from mcp.server.streamable_http import EventStore
17
  from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
 
18
  from starlette.applications import Starlette
19
  from starlette.middleware import Middleware
20
  from starlette.middleware.authentication import AuthenticationMiddleware
@@ -307,6 +308,14 @@ def create_streamable_http_app(
307
 
308
  # Add StreamableHTTP routes with or without auth
309
  if auth:
 
 
 
 
 
 
 
 
310
  auth_middleware, auth_routes, required_scopes = (
311
  setup_auth_middleware_and_routes(auth)
312
  )
@@ -318,7 +327,9 @@ def create_streamable_http_app(
318
  server_routes.append(
319
  Mount(
320
  streamable_http_path,
321
- app=RequireAuthMiddleware(handle_streamable_http, required_scopes),
 
 
322
  )
323
  )
324
  else:
 
15
  from mcp.server.sse import SseServerTransport
16
  from mcp.server.streamable_http import EventStore
17
  from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
18
+ from pydantic import AnyHttpUrl
19
  from starlette.applications import Starlette
20
  from starlette.middleware import Middleware
21
  from starlette.middleware.authentication import AuthenticationMiddleware
 
308
 
309
  # Add StreamableHTTP routes with or without auth
310
  if auth:
311
+ resource_metadata_url = None
312
+
313
+ if auth.resource_server_url:
314
+ resource_metadata_url = AnyHttpUrl(
315
+ str(auth.resource_server_url).rstrip("/")
316
+ + "/.well-known/oauth-protected-resource"
317
+ )
318
+
319
  auth_middleware, auth_routes, required_scopes = (
320
  setup_auth_middleware_and_routes(auth)
321
  )
 
327
  server_routes.append(
328
  Mount(
329
  streamable_http_path,
330
+ app=RequireAuthMiddleware(
331
+ handle_streamable_http, required_scopes, resource_metadata_url
332
+ ),
333
  )
334
  )
335
  else:
tests/server/http/test_http_auth_middleware.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+ from mcp.server.auth.middleware.bearer_auth import RequireAuthMiddleware
3
+ from starlette.routing import Mount
4
+
5
+ from fastmcp.server import FastMCP
6
+ from fastmcp.server.auth.providers.bearer import BearerAuthProvider, RSAKeyPair
7
+ from fastmcp.server.http import create_streamable_http_app
8
+
9
+
10
+ class TestStreamableHTTPAppResourceMetadataURL:
11
+ """Test resource_metadata_url logic in create_streamable_http_app."""
12
+
13
+ @pytest.fixture
14
+ def rsa_key_pair(self) -> RSAKeyPair:
15
+ """Generate RSA key pair for testing."""
16
+ return RSAKeyPair.generate()
17
+
18
+ @pytest.fixture
19
+ def bearer_auth_provider(self, rsa_key_pair):
20
+ provider = BearerAuthProvider(
21
+ public_key=rsa_key_pair.public_key,
22
+ issuer="https://issuer",
23
+ audience="https://audience",
24
+ resource_server="https://resource.example.com",
25
+ )
26
+ return provider
27
+
28
+ def test_require_auth_middleware_receives_resource_metadata_url(
29
+ self, bearer_auth_provider
30
+ ):
31
+ server = FastMCP(name="TestServer")
32
+
33
+ app = create_streamable_http_app(
34
+ server=server,
35
+ streamable_http_path="/mcp",
36
+ auth=bearer_auth_provider,
37
+ )
38
+
39
+ mount = next(r for r in app.routes if isinstance(r, Mount) and r.path == "/mcp")
40
+
41
+ assert isinstance(mount.app, RequireAuthMiddleware)
42
+ assert (
43
+ str(mount.app.resource_metadata_url)
44
+ == "https://resource.example.com/.well-known/oauth-protected-resource"
45
+ )
46
+
47
+ def test_trailing_slash_handling_in_resource_server_url(self, rsa_key_pair):
48
+ provider = BearerAuthProvider(
49
+ public_key=rsa_key_pair.public_key,
50
+ issuer="https://issuer",
51
+ audience="https://audience",
52
+ resource_server="https://resource.example.com/",
53
+ )
54
+ server = FastMCP(name="TestServer")
55
+ app = create_streamable_http_app(
56
+ server=server,
57
+ streamable_http_path="/mcp",
58
+ auth=provider,
59
+ )
60
+ mount = next(r for r in app.routes if isinstance(r, Mount) and r.path == "/mcp")
61
+ assert isinstance(mount.app, RequireAuthMiddleware)
62
+ # Should not have double slash
63
+ assert (
64
+ str(mount.app.resource_metadata_url)
65
+ == "https://resource.example.com/.well-known/oauth-protected-resource"
66
+ )
67
+
68
+ def test_no_auth_provider_mounts_without_require_auth_middleware(
69
+ self, rsa_key_pair
70
+ ):
71
+ server = FastMCP(name="TestServer")
72
+ app = create_streamable_http_app(
73
+ server=server,
74
+ streamable_http_path="/mcp",
75
+ auth=None,
76
+ )
77
+ mount = next(r for r in app.routes if isinstance(r, Mount) and r.path == "/mcp")
78
+ assert not isinstance(mount.app, RequireAuthMiddleware)