Skip to content

Commit e5c06bb

Browse files
committed
feat: fetch server confg utils
1 parent ac21895 commit e5c06bb

File tree

8 files changed

+981
-7
lines changed

8 files changed

+981
-7
lines changed

.vscode/settings.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
11
{
2-
"python.analysis.typeCheckingMode": "strict"
2+
"python.analysis.typeCheckingMode": "strict",
3+
"python.analysis.extraPaths": [
4+
"./typings"
5+
]
36
}

mcpauth/exceptions.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
from enum import Enum
22
from pydantic import BaseModel
3-
from typing import Any, Optional, List, Dict, Union
3+
from typing import Optional, List, Dict, Union
4+
from .types import Record
45

5-
Record = Dict[str, Any]
66
ExceptionCause = Optional[Union[Record, Exception]]
77

88

mcpauth/types.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
from typing import Any, Dict
2+
3+
4+
Record = Dict[str, Any]

mcpauth/utils/fetch_server_config.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
from enum import Enum
2+
from typing import Callable, Optional
3+
from urllib.parse import urlparse, urlunparse
4+
import aiohttp
5+
import pydantic
6+
from pathlib import Path
7+
8+
from ..types import Record
9+
from ..models.oauth import AuthorizationServerMetadata
10+
from ..models.auth_server import AuthServerConfig, AuthServerType
11+
from ..exceptions import MCPAuthConfigException
12+
13+
14+
class ServerMetadataPaths(str, Enum):
15+
"""
16+
Enum for server metadata paths.
17+
This is used to define the standard paths for OAuth and OIDC well-known URLs.
18+
"""
19+
20+
OAUTH = "/.well-known/oauth-authorization-server"
21+
OIDC = "/.well-known/openid-configuration"
22+
23+
24+
def smart_join(*args: str) -> str:
25+
return Path("/".join(arg.strip("/") for arg in args)).as_posix()
26+
27+
28+
def get_oauth_well_known_url(issuer: str) -> str:
29+
parsed_url = urlparse(issuer)
30+
new_path = smart_join(ServerMetadataPaths.OAUTH.value, parsed_url.path)
31+
return urlunparse(parsed_url._replace(path=new_path))
32+
33+
34+
def get_oidc_well_known_url(issuer: str) -> str:
35+
parsed = urlparse(issuer)
36+
new_path = smart_join(parsed.path, ServerMetadataPaths.OIDC.value)
37+
return urlunparse(parsed._replace(path=new_path))
38+
39+
40+
async def fetch_server_config_by_well_known_url(
41+
well_known_url: str,
42+
type: AuthServerType,
43+
transpile_data: Optional[Callable[[Record], Record]] = None,
44+
) -> AuthServerConfig:
45+
try:
46+
async with aiohttp.ClientSession() as session:
47+
async with session.get(well_known_url) as response:
48+
response.raise_for_status()
49+
json = await response.json()
50+
transpiled_data = transpile_data(json) if transpile_data else json
51+
return AuthServerConfig(
52+
metadata=AuthorizationServerMetadata(**transpiled_data), type=type
53+
)
54+
except pydantic.ValidationError as e:
55+
raise MCPAuthConfigException(
56+
"invalid_server_metadata",
57+
f"Invalid server metadata from {well_known_url}: {str(e)}",
58+
cause=e,
59+
) from e
60+
except Exception as e:
61+
raise MCPAuthConfigException(
62+
"fetch_server_config_error",
63+
f"Failed to fetch server config from {well_known_url}: {str(e)}",
64+
cause=e,
65+
) from e
66+
67+
68+
async def fetch_server_config(
69+
issuer: str,
70+
type: AuthServerType,
71+
transpile_data: Optional[Callable[[Record], Record]] = None,
72+
) -> AuthServerConfig:
73+
well_known_url = (
74+
get_oauth_well_known_url(issuer)
75+
if type == AuthServerType.OAUTH
76+
else get_oidc_well_known_url(issuer)
77+
)
78+
return await fetch_server_config_by_well_known_url(
79+
well_known_url, type, transpile_data
80+
)
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
import pytest
2+
from aresponses import ResponsesMockServer
3+
from aiohttp.web_response import Response
4+
5+
from mcpauth.models.auth_server import AuthServerType
6+
from mcpauth.exceptions import MCPAuthConfigException
7+
from mcpauth.types import Record
8+
from mcpauth.utils.fetch_server_config import (
9+
ServerMetadataPaths,
10+
fetch_server_config,
11+
fetch_server_config_by_well_known_url,
12+
)
13+
14+
15+
@pytest.mark.asyncio
16+
class TestFetchServerConfigByWellKnownUrl:
17+
async def test_fetch_server_config_by_well_known_url_fetch_fails(
18+
self, aresponses: ResponsesMockServer
19+
):
20+
sample_issuer = "https://example.com"
21+
sample_well_known_url = sample_issuer + ServerMetadataPaths.OAUTH.value
22+
23+
aresponses.add(
24+
"example.com",
25+
ServerMetadataPaths.OAUTH.value,
26+
"GET",
27+
Response(text="Internal Server Error", status=500),
28+
)
29+
30+
with pytest.raises(MCPAuthConfigException) as exc_info:
31+
await fetch_server_config_by_well_known_url(
32+
sample_well_known_url, AuthServerType.OAUTH
33+
)
34+
35+
assert "Failed to fetch server config" in str(exc_info.value)
36+
37+
async def test_fetch_server_config_by_well_known_url_invalid_metadata(
38+
self, aresponses: ResponsesMockServer
39+
):
40+
sample_issuer = "https://example.com"
41+
sample_well_known_url = sample_issuer + ServerMetadataPaths.OAUTH.value
42+
43+
aresponses.add(
44+
"example.com", ServerMetadataPaths.OAUTH.value, "GET", response={}
45+
)
46+
47+
with pytest.raises(MCPAuthConfigException) as exc_info:
48+
await fetch_server_config_by_well_known_url(
49+
sample_well_known_url, AuthServerType.OAUTH
50+
)
51+
52+
assert "Invalid server metadata" in str(exc_info.value)
53+
54+
async def test_fetch_server_config_by_well_known_url_malformed_metadata(
55+
self, aresponses: ResponsesMockServer
56+
):
57+
sample_issuer = "https://example.com"
58+
sample_well_known_url = sample_issuer + ServerMetadataPaths.OAUTH.value
59+
60+
sample_response = {
61+
"issuer": sample_issuer,
62+
"authorization_endpoint": "https://example.com/oauth/authorize",
63+
"token_endpoint": "https://example.com/oauth/token",
64+
}
65+
66+
aresponses.add(
67+
"example.com", ServerMetadataPaths.OAUTH.value, "GET", sample_response
68+
)
69+
70+
with pytest.raises(MCPAuthConfigException) as exc_info:
71+
await fetch_server_config_by_well_known_url(
72+
sample_well_known_url, AuthServerType.OAUTH
73+
)
74+
75+
assert "Invalid server metadata" in str(exc_info.value)
76+
77+
async def test_fetch_server_config_by_well_known_url_success_with_transpile(
78+
self, aresponses: ResponsesMockServer
79+
):
80+
sample_issuer = "https://example.com"
81+
sample_well_known_url = sample_issuer + ServerMetadataPaths.OAUTH.value
82+
83+
sample_response = {
84+
"issuer": sample_issuer,
85+
"authorization_endpoint": "https://example.com/oauth/authorize",
86+
"token_endpoint": "https://example.com/oauth/token",
87+
}
88+
89+
def transpile(data: Record) -> Record:
90+
return {**data, "response_types_supported": ["code"]}
91+
92+
aresponses.add(
93+
"example.com",
94+
ServerMetadataPaths.OAUTH.value,
95+
"GET",
96+
sample_response,
97+
)
98+
99+
config = await fetch_server_config_by_well_known_url(
100+
sample_well_known_url,
101+
AuthServerType.OAUTH,
102+
transpile_data=transpile,
103+
)
104+
105+
assert config.type == AuthServerType.OAUTH
106+
assert config.metadata.issuer == sample_issuer
107+
assert (
108+
config.metadata.authorization_endpoint
109+
== "https://example.com/oauth/authorize"
110+
)
111+
assert config.metadata.token_endpoint == "https://example.com/oauth/token"
112+
assert config.metadata.response_types_supported == ["code"]
113+
114+
async def test_fetch_server_config_oauth_success(
115+
self, aresponses: ResponsesMockServer
116+
):
117+
issuer = "https://example.com"
118+
sample_response: Record = {
119+
"issuer": issuer + "/",
120+
"authorization_endpoint": "https://example.com/oauth/authorize",
121+
"token_endpoint": "https://example.com/oauth/token",
122+
"response_types_supported": ["code"],
123+
}
124+
125+
aresponses.add(
126+
"example.com",
127+
ServerMetadataPaths.OAUTH.value,
128+
"GET",
129+
sample_response,
130+
)
131+
132+
config = await fetch_server_config(issuer, AuthServerType.OAUTH)
133+
134+
assert config.type == AuthServerType.OAUTH
135+
assert config.metadata.issuer == issuer + "/"
136+
assert (
137+
config.metadata.authorization_endpoint
138+
== "https://example.com/oauth/authorize"
139+
)
140+
assert config.metadata.token_endpoint == "https://example.com/oauth/token"
141+
assert config.metadata.response_types_supported == ["code"]
142+
143+
async def test_fetch_server_config_oauth_with_path_success(
144+
self, aresponses: ResponsesMockServer
145+
):
146+
issuer = "https://example.com/path"
147+
sample_response: Record = {
148+
"issuer": issuer,
149+
"authorization_endpoint": "https://example.com/oauth/authorize",
150+
"token_endpoint": "https://example.com/oauth/token",
151+
"response_types_supported": ["code"],
152+
}
153+
154+
aresponses.add(
155+
"example.com",
156+
ServerMetadataPaths.OAUTH.value + "/path",
157+
"GET",
158+
sample_response,
159+
)
160+
161+
config = await fetch_server_config(issuer, AuthServerType.OAUTH)
162+
163+
assert config.type == AuthServerType.OAUTH
164+
assert config.metadata.issuer == issuer
165+
assert (
166+
config.metadata.authorization_endpoint
167+
== "https://example.com/oauth/authorize"
168+
)
169+
assert config.metadata.token_endpoint == "https://example.com/oauth/token"
170+
assert config.metadata.response_types_supported == ["code"]
171+
172+
async def test_fetch_server_config_oidc_success(
173+
self, aresponses: ResponsesMockServer
174+
):
175+
issuer = "https://example.com"
176+
sample_response: Record = {
177+
"issuer": issuer + "/",
178+
"authorization_endpoint": "https://example.com/authorize",
179+
"token_endpoint": "https://example.com/token",
180+
"response_types_supported": ["code"],
181+
}
182+
183+
aresponses.add(
184+
"example.com",
185+
ServerMetadataPaths.OIDC.value,
186+
"GET",
187+
sample_response,
188+
)
189+
190+
config = await fetch_server_config(issuer, AuthServerType.OIDC)
191+
192+
assert config.type == AuthServerType.OIDC
193+
assert config.metadata.issuer == issuer + "/"
194+
assert config.metadata.authorization_endpoint == "https://example.com/authorize"
195+
assert config.metadata.token_endpoint == "https://example.com/token"
196+
assert config.metadata.response_types_supported == ["code"]
197+
198+
async def test_fetch_server_config_oidc_with_path_success(
199+
self, aresponses: ResponsesMockServer
200+
):
201+
issuer = "https://example.com/path"
202+
sample_response: Record = {
203+
"issuer": issuer,
204+
"authorization_endpoint": issuer + "/authorize",
205+
"token_endpoint": issuer + "/token",
206+
"response_types_supported": ["code"],
207+
}
208+
209+
aresponses.add(
210+
"example.com",
211+
"/path/.well-known/openid-configuration",
212+
"GET",
213+
sample_response,
214+
)
215+
216+
config = await fetch_server_config(issuer, AuthServerType.OIDC)
217+
218+
assert config.type == AuthServerType.OIDC
219+
assert config.metadata.issuer == issuer
220+
assert config.metadata.authorization_endpoint == issuer + "/authorize"
221+
assert config.metadata.token_endpoint == issuer + "/token"
222+
assert config.metadata.response_types_supported == ["code"]
223+
224+
async def test_fetch_server_config_oidc_failure(
225+
self, aresponses: ResponsesMockServer
226+
):
227+
issuer = "https://example.com"
228+
229+
aresponses.add(
230+
"example.com",
231+
ServerMetadataPaths.OIDC.value,
232+
"GET",
233+
Response(text="Internal Server Error", status=500),
234+
)
235+
236+
with pytest.raises(MCPAuthConfigException) as exc_info:
237+
await fetch_server_config(issuer, AuthServerType.OIDC)
238+
239+
assert "Failed to fetch server config" in str(exc_info.value)

pyproject.toml

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ keywords = [
1313
"oauth2",
1414
"openid-connect",
1515
]
16-
dependencies = ["pydantic>=2.11.3", "pyjwt[crypto]>=2.9.0"]
16+
dependencies = ["aiohttp>=3.11.18", "pydantic>=2.11.3", "pyjwt[crypto]>=2.9.0"]
1717

1818
[project.urls]
1919
homepage = "https://mcp-auth.dev"
@@ -22,7 +22,9 @@ documentation = "https://mcp-auth.dev/docs"
2222

2323
[dependency-groups]
2424
dev = [
25-
"black>=24.8.0",
26-
"pytest>=8.3.5",
27-
"pytest-cov>=6.1.1",
25+
"aresponses>=3.0.0",
26+
"black>=24.8.0",
27+
"pytest>=8.3.5",
28+
"pytest-asyncio>=0.26.0",
29+
"pytest-cov>=6.1.1",
2830
]

typings/aresponses/__init__.pyi

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Created by ChatGPT, edited for precision
2+
3+
from typing import Any, Union
4+
from aiohttp.web_response import Response
5+
6+
class ResponsesMockServer:
7+
def add(
8+
self,
9+
host: str,
10+
path: str,
11+
method: str,
12+
response: Union[Response, Any],
13+
) -> None: ...
14+
async def start(self) -> None: ...
15+
async def close(self) -> None: ...
16+
def __aenter__(self) -> "ResponsesMockServer": ...
17+
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: ...

0 commit comments

Comments
 (0)