Skip to content

Commit d9d09fa

Browse files
Merge pull request #972 from awork-io/oauth-mcp-metadata
fix: align external OAuth metadata with MCP path
2 parents f5d969a + f1bcaa7 commit d9d09fa

2 files changed

Lines changed: 63 additions & 3 deletions

File tree

auth/external_oauth_provider.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,8 @@ def __init__(
8080
):
8181
"""Initialize and store client credentials for token validation."""
8282
self._resource_server_url = resource_server_url
83+
if resource_server_url and "resource_base_url" not in kwargs:
84+
kwargs["resource_base_url"] = resource_server_url
8385
super().__init__(client_id=client_id, client_secret=client_secret, **kwargs)
8486
# Store credentials as they're not exposed by parent class
8587
self._client_id = client_id
@@ -150,7 +152,7 @@ async def verify_token(self, token: str) -> Optional[AccessToken]:
150152
# For JWT tokens, use parent class implementation
151153
return await super().verify_token(token)
152154

153-
def get_routes(self, **kwargs) -> list[Route]:
155+
def get_routes(self, mcp_path: str | None = None) -> list[Route]:
154156
"""
155157
Get OAuth routes for external provider mode.
156158
@@ -159,7 +161,7 @@ def get_routes(self, **kwargs) -> list[Route]:
159161
(/authorize, /token, etc.) since tokens are issued by Google directly.
160162
161163
Args:
162-
**kwargs: Additional arguments passed by FastMCP (e.g., mcp_path)
164+
mcp_path: Path where FastMCP mounts the protected MCP endpoint.
163165
164166
Returns:
165167
List of routes - only protected resource metadata
@@ -172,10 +174,18 @@ def get_routes(self, **kwargs) -> list[Route]:
172174
)
173175
return []
174176

177+
self.set_mcp_path(mcp_path)
178+
resource_url = self._get_resource_url(mcp_path)
179+
if not resource_url:
180+
logger.warning(
181+
"ExternalOAuthProvider: protected resource URL could not be resolved"
182+
)
183+
return []
184+
175185
# Create protected resource routes that point to Google as the authorization server
176186
# Pass strings directly - Pydantic validates them during model construction
177187
protected_routes = create_protected_resource_routes(
178-
resource_url=self.resource_server_url,
188+
resource_url=resource_url,
179189
authorization_servers=[GOOGLE_ISSUER_URL],
180190
scopes_supported=self.required_scopes,
181191
resource_name="Google Workspace MCP",

tests/core/test_well_known_cache_control_middleware.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,3 +233,53 @@ def test_configured_server_applies_no_cache_to_served_oauth_discovery_routes(
233233
# Ensure we did not create a shadow route at the wrong path.
234234
wrong_path = client.get("/.well-known/oauth-protected-resource")
235235
assert wrong_path.status_code == 404
236+
237+
238+
def test_external_oauth_metadata_matches_mcp_resource_and_challenge(monkeypatch):
239+
monkeypatch.setenv("MCP_ENABLE_OAUTH21", "true")
240+
monkeypatch.setenv("GOOGLE_OAUTH_CLIENT_ID", "dummy-client")
241+
monkeypatch.setenv("GOOGLE_OAUTH_CLIENT_SECRET", "dummy-secret")
242+
monkeypatch.setenv("WORKSPACE_MCP_BASE_URI", "http://localhost")
243+
monkeypatch.setenv("WORKSPACE_MCP_PORT", "8000")
244+
monkeypatch.setenv("WORKSPACE_EXTERNAL_URL", "https://workspace.example.com")
245+
monkeypatch.setenv("EXTERNAL_OAUTH21_PROVIDER", "true")
246+
monkeypatch.setenv("WORKSPACE_MCP_STATELESS_MODE", "true")
247+
248+
import core.server as core_server
249+
from auth.oauth_config import reload_oauth_config
250+
251+
reload_oauth_config()
252+
core_server = importlib.reload(core_server)
253+
core_server.set_transport_mode("streamable-http")
254+
core_server.configure_server_for_http()
255+
256+
app = core_server.server.http_app(transport="streamable-http", path="/mcp")
257+
client = TestClient(app)
258+
259+
protected_resource = client.get("/.well-known/oauth-protected-resource/mcp")
260+
assert protected_resource.status_code == 200
261+
assert protected_resource.json()["resource"] == "https://workspace.example.com/mcp"
262+
263+
wrong_path = client.get("/.well-known/oauth-protected-resource")
264+
assert wrong_path.status_code == 404
265+
266+
challenge = client.post(
267+
"/mcp",
268+
headers={"Accept": "application/json, text/event-stream"},
269+
json={
270+
"jsonrpc": "2.0",
271+
"id": 1,
272+
"method": "initialize",
273+
"params": {
274+
"protocolVersion": "2025-06-18",
275+
"capabilities": {},
276+
"clientInfo": {"name": "test", "version": "1"},
277+
},
278+
},
279+
)
280+
assert challenge.status_code == 401
281+
assert (
282+
'resource_metadata="https://workspace.example.com/'
283+
'.well-known/oauth-protected-resource/mcp"'
284+
in challenge.headers["www-authenticate"]
285+
)

0 commit comments

Comments
 (0)