Skip to content

Commit a9c75d4

Browse files
authored
feat: support protected resource metadata for mcp server (#27)
* feat: support protected resource metadata for mcp server * test: add tests * chore: address review comments
1 parent e76fe7c commit a9c75d4

22 files changed

+1770
-332
lines changed

mcpauth/__init__.py

Lines changed: 100 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,22 @@
11
from contextvars import ContextVar
2-
import logging
3-
from typing import Any, Callable, List, Literal, Optional, Union
4-
2+
from typing import Callable, List, Literal, Optional, Union
3+
from typing_extensions import deprecated
4+
5+
from .auth.authorization_server_handler import (
6+
AuthorizationServerHandler,
7+
AuthServerModeConfig,
8+
)
9+
from .auth.mcp_auth_handler import MCPAuthHandler
10+
from .auth.resource_server_handler import (
11+
ResourceServerHandler,
12+
ResourceServerModeConfig,
13+
)
514
from .middleware.create_bearer_auth import BearerAuthConfig
6-
from .types import AuthInfo, VerifyAccessTokenFunction
7-
from .config import AuthServerConfig, ServerMetadataPaths
15+
from .types import AuthInfo, ResourceServerConfig, VerifyAccessTokenFunction
16+
from .config import AuthServerConfig
817
from .exceptions import MCPAuthAuthServerException, AuthServerExceptionCode
9-
from .utils import validate_server_config
1018
from starlette.middleware.base import BaseHTTPMiddleware
11-
from starlette.responses import Response, JSONResponse
12-
from starlette.requests import Request
13-
from starlette.routing import Route
19+
from starlette.routing import Router, Route
1420

1521
_context_var_name = "mcp_auth_context"
1622

@@ -23,41 +29,47 @@ class MCPAuth:
2329
See Also: https://mcp-auth.dev for more information about the library and its usage.
2430
"""
2531

26-
server: AuthServerConfig
27-
"""
28-
The configuration for the remote authorization server.
29-
"""
32+
_handler: MCPAuthHandler
3033

3134
def __init__(
3235
self,
33-
server: AuthServerConfig,
36+
server: Optional[AuthServerConfig] = None,
37+
protected_resources: Optional[
38+
Union[ResourceServerConfig, List[ResourceServerConfig]]
39+
] = None,
3440
context_var: ContextVar[Optional[AuthInfo]] = ContextVar(
3541
_context_var_name, default=None
3642
),
3743
):
3844
"""
39-
:param server: Configuration for the remote authorization server.
45+
:param server: Configuration for the remote authorization server (deprecated).
46+
:param protected_resources: Configuration for one or more protected resource servers.
4047
:param context_var: Context variable to store the `AuthInfo` object for the current request.
4148
By default, it will be created with the name "mcp_auth_context".
4249
"""
4350

44-
result = validate_server_config(server)
51+
if server and protected_resources:
52+
raise MCPAuthAuthServerException(
53+
AuthServerExceptionCode.INVALID_SERVER_CONFIG,
54+
cause={
55+
"error_description": "Either `server` or `protected_resources` must be provided, but not both."
56+
},
57+
)
4558

46-
if not result.is_valid:
47-
logging.error(
48-
"The authorization server configuration is invalid:\n"
49-
f"{result.errors}\n"
59+
if server:
60+
self._handler = AuthorizationServerHandler(AuthServerModeConfig(server))
61+
elif protected_resources:
62+
self._handler = ResourceServerHandler(
63+
ResourceServerModeConfig(protected_resources)
5064
)
65+
else:
5166
raise MCPAuthAuthServerException(
52-
AuthServerExceptionCode.INVALID_SERVER_CONFIG, cause=result
67+
AuthServerExceptionCode.INVALID_SERVER_CONFIG,
68+
cause={
69+
"error_description": "Either `server` or `protected_resources` must be provided."
70+
},
5371
)
5472

55-
if len(result.warnings) > 0:
56-
logging.warning("The authorization server configuration has warnings:\n")
57-
for warning in result.warnings:
58-
logging.warning(f"- {warning}")
59-
60-
self.server = server
6173
self._context_var = context_var
6274

6375
@property
@@ -72,64 +84,48 @@ def auth_info(self) -> Optional[AuthInfo]:
7284

7385
return self._context_var.get()
7486

75-
def metadata_endpoint(self) -> Callable[[Request], Any]:
87+
@deprecated("Use resource_metadata_router() instead for resource server mode")
88+
def metadata_route(self) -> Route:
7689
"""
77-
Returns a Starlette endpoint function that handles the OAuth 2.0 Authorization Metadata
78-
endpoint (`/.well-known/oauth-authorization-server`) with CORS support.
79-
80-
Example:
81-
```python
82-
from starlette.applications import Starlette
83-
from mcpauth import MCPAuth
84-
from mcpauth.config import ServerMetadataPaths
85-
86-
mcp_auth = MCPAuth(server=your_server_config)
87-
app = Starlette(routes=[
88-
Route(
89-
ServerMetadataPaths.OAUTH.value,
90-
mcp_auth.metadata_endpoint(),
91-
methods=["GET", "OPTIONS"] # Ensure to handle both GET and OPTIONS methods
92-
)
93-
])
94-
```
90+
Returns a router that handles the legacy OAuth 2.0 Authorization Server Metadata endpoint.
91+
92+
This method is deprecated and will be removed in a future version.
93+
For resource server mode, use `resource_metadata_router()` instead to serve
94+
the Protected Resource Metadata endpoints.
9595
"""
96+
if isinstance(self._handler, ResourceServerHandler):
97+
raise MCPAuthAuthServerException(
98+
AuthServerExceptionCode.INVALID_SERVER_CONFIG,
99+
cause={
100+
"error_description": "`metadata_route` is not available in `resource server` mode. Use `resource_metadata_router()` instead."
101+
},
102+
)
96103

97-
async def endpoint(request: Request) -> Response:
98-
if request.method == "OPTIONS":
99-
response = Response(status_code=204)
100-
else:
101-
server_config = self.server
102-
response = JSONResponse(
103-
server_config.metadata.model_dump(exclude_none=True),
104-
status_code=200,
105-
)
106-
response.headers["Access-Control-Allow-Origin"] = "*"
107-
response.headers["Access-Control-Allow-Methods"] = "GET, OPTIONS"
108-
response.headers["Access-Control-Allow-Headers"] = "*"
109-
return response
110-
111-
return endpoint
104+
oauth_metadata_route = self._handler.create_metadata_route().routes[0]
112105

113-
def metadata_route(self) -> Route:
114-
"""
115-
Returns a Starlette route that handles the OAuth 2.0 Authorization Metadata endpoint
116-
(`/.well-known/oauth-authorization-server`) with CORS support.
106+
if not isinstance(oauth_metadata_route, Route):
107+
raise IndexError(
108+
"No metadata endpoint route was created. Expected the authorization server metadata route to be present."
109+
)
117110

118-
Example:
119-
```python
120-
from starlette.applications import Starlette
121-
from mcpauth import MCPAuth
111+
return oauth_metadata_route
122112

123-
mcp_auth = MCPAuth(server=your_server_config)
124-
app = Starlette(routes=[mcp_auth.metadata_route()])
125-
```
113+
def resource_metadata_router(self) -> Router:
126114
"""
115+
Returns a router that serves the OAuth 2.0 Protected Resource Metadata endpoint
116+
for all configured resources.
127117
128-
return Route(
129-
ServerMetadataPaths.OAUTH.value,
130-
self.metadata_endpoint(),
131-
methods=["GET", "OPTIONS"],
132-
)
118+
This is an alias for `metadata_route` and is the recommended method to use when
119+
in "resource server" mode.
120+
"""
121+
if isinstance(self._handler, AuthorizationServerHandler):
122+
raise MCPAuthAuthServerException(
123+
AuthServerExceptionCode.INVALID_SERVER_CONFIG,
124+
cause={
125+
"error_description": "`resource_metadata_router` is not available in `authorization server` mode."
126+
},
127+
)
128+
return self._handler.create_metadata_route()
133129

134130
def bearer_auth_middleware(
135131
self,
@@ -138,6 +134,7 @@ def bearer_auth_middleware(
138134
required_scopes: Optional[List[str]] = None,
139135
show_error_details: bool = False,
140136
leeway: float = 60,
137+
resource: Optional[str] = None,
141138
) -> type[BaseHTTPMiddleware]:
142139
"""
143140
Creates a middleware that handles bearer token authentication.
@@ -150,38 +147,53 @@ def bearer_auth_middleware(
150147
Defaults to `False`.
151148
:param leeway: Optional leeway in seconds for JWT verification (`jwt.decode`). Defaults to
152149
`60`. Not used if a custom function is provided.
150+
:param resource: The identifier of the protected resource. Required when using `protected_resources`.
153151
:return: A middleware class that can be used in a Starlette or FastAPI application.
154152
"""
153+
from .middleware.create_bearer_auth import create_bearer_auth
155154

156-
metadata = self.server.metadata
157-
if isinstance(mode_or_verify, str) and mode_or_verify == "jwt":
158-
from .utils import create_verify_jwt
155+
issuer: Union[str, Callable[[str], None]]
156+
157+
resource_for_verifier: str
159158

160-
if not metadata.jwks_uri:
159+
if isinstance(self._handler, ResourceServerHandler):
160+
if not resource:
161161
raise MCPAuthAuthServerException(
162-
AuthServerExceptionCode.MISSING_JWKS_URI
162+
AuthServerExceptionCode.INVALID_SERVER_CONFIG,
163+
cause={
164+
"error_description": "A `resource` must be specified in the `bearer_auth_middleware` configuration when using a `protected_resources` configuration."
165+
},
163166
)
167+
resource_for_verifier = resource
168+
else: # AuthorizationServerHandler
169+
# In the deprecated `authorization server` mode, `getTokenVerifier` does not utilize the
170+
# `resource` parameter. Passing an empty string `''` is a straightforward approach that
171+
# avoids over-engineering a solution for a legacy path.
172+
resource_for_verifier = ""
164173

165-
verify = create_verify_jwt(
166-
metadata.jwks_uri,
167-
leeway=leeway,
174+
if isinstance(mode_or_verify, str) and mode_or_verify == "jwt":
175+
token_verifier = self._handler.get_token_verifier(
176+
resource=resource_for_verifier
168177
)
178+
verify = token_verifier.create_verify_jwt_function(leeway=leeway)
179+
issuer = token_verifier.validate_jwt_issuer
169180
elif callable(mode_or_verify):
170181
verify = mode_or_verify
182+
# For custom verify functions, issuer validation should be handled by the custom logic
183+
issuer = lambda _: None # No-op function that accepts any issuer
171184
else:
172185
raise ValueError(
173186
"mode_or_verify must be 'jwt' or a callable function that verifies tokens."
174187
)
175188

176-
from .middleware.create_bearer_auth import create_bearer_auth
177-
178189
return create_bearer_auth(
179190
verify,
180191
config=BearerAuthConfig(
181-
issuer=metadata.issuer,
192+
issuer=issuer,
182193
audience=audience,
183194
required_scopes=required_scopes,
184195
show_error_details=show_error_details,
196+
resource=resource,
185197
),
186198
context_var=self._context_var,
187199
)
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import logging
2+
from typing import Any, Callable
3+
4+
from starlette.routing import Route, Router
5+
from starlette.requests import Request
6+
from starlette.responses import Response, JSONResponse
7+
8+
from ..config import AuthServerConfig, ServerMetadataPaths
9+
from ..exceptions import AuthServerExceptionCode, MCPAuthAuthServerException
10+
from ..utils import validate_server_config
11+
from .mcp_auth_handler import MCPAuthHandler
12+
from .token_verifier import TokenVerifier
13+
14+
15+
class AuthServerModeConfig:
16+
"""
17+
Configuration for the legacy, MCP-server-as-authorization-server mode.
18+
"""
19+
20+
def __init__(self, server: AuthServerConfig):
21+
self.server = server
22+
23+
24+
class AuthorizationServerHandler(MCPAuthHandler):
25+
"""
26+
Handles the authentication logic for the legacy `server` mode.
27+
"""
28+
29+
def __init__(self, config: AuthServerModeConfig):
30+
logging.warning(
31+
"The authorization server mode is deprecated. Please use resource server mode instead."
32+
)
33+
34+
result = validate_server_config(config.server)
35+
36+
if not result.is_valid:
37+
logging.error(
38+
"The authorization server configuration is invalid:\n"
39+
f"{result.errors}\n"
40+
)
41+
raise MCPAuthAuthServerException(
42+
AuthServerExceptionCode.INVALID_SERVER_CONFIG, cause=result
43+
)
44+
45+
if len(result.warnings) > 0:
46+
logging.warning("The authorization server configuration has warnings:\n")
47+
for warning in result.warnings:
48+
logging.warning(f"- {warning}")
49+
50+
self.server = config.server
51+
self.token_verifier = TokenVerifier([config.server])
52+
53+
def create_metadata_route(self) -> Router:
54+
"""
55+
Returns a Starlette route that handles the OAuth 2.0 Authorization Metadata endpoint
56+
(`/.well-known/oauth-authorization-server`) with CORS support.
57+
"""
58+
routes = [
59+
Route(
60+
ServerMetadataPaths.OAUTH.value,
61+
self._create_metadata_endpoint(),
62+
methods=["GET", "OPTIONS"],
63+
)
64+
]
65+
return Router(routes=routes)
66+
67+
def _create_metadata_endpoint(self) -> Callable[[Request], Any]:
68+
"""
69+
Returns a Starlette endpoint function that handles the OAuth 2.0 Authorization Metadata
70+
endpoint (`/.well-known/oauth-authorization-server`) with CORS support.
71+
"""
72+
73+
def endpoint(request: Request) -> Response:
74+
if request.method == "OPTIONS":
75+
response = Response(status_code=204)
76+
else:
77+
response = JSONResponse(
78+
self.server.metadata.model_dump(exclude_none=True),
79+
status_code=200,
80+
)
81+
response.headers["Access-Control-Allow-Origin"] = "*"
82+
response.headers["Access-Control-Allow-Methods"] = "GET, OPTIONS"
83+
response.headers["Access-Control-Allow-Headers"] = "*"
84+
return response
85+
86+
return endpoint
87+
88+
def get_token_verifier(self, resource: str) -> TokenVerifier:
89+
"""
90+
This is a dummy implementation that ignores the resource, as there is only
91+
one `TokenVerifier` in the authorization server mode.
92+
"""
93+
return self.token_verifier

mcpauth/auth/mcp_auth_handler.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
from abc import ABC, abstractmethod
2+
3+
from starlette.routing import Router
4+
5+
from .token_verifier import TokenVerifier
6+
7+
8+
class MCPAuthHandler(ABC):
9+
"""
10+
Defines the contract for a handler that manages the logic for a specific MCPAuth configuration.
11+
This allows for clean separation of logic between legacy and modern configurations.
12+
"""
13+
14+
@abstractmethod
15+
def create_metadata_route(self) -> Router:
16+
"""
17+
Returns a router for serving either the legacy OAuth 2.0 Authorization Server Metadata or
18+
the OAuth 2.0 Protected Resource Metadata, depending on the configuration.
19+
"""
20+
... # pragma: no cover
21+
22+
@abstractmethod
23+
def get_token_verifier(self, resource: str) -> TokenVerifier:
24+
"""
25+
Resolves the appropriate TokenVerifier based on the provided resource.
26+
:param resource: The resource identifier for verifier lookup.
27+
"""
28+
... # pragma: no cover

0 commit comments

Comments
 (0)