|
24 | 24 | from fastmcp.server.auth.jwt_issuer import derive_jwt_key |
25 | 25 | from fastmcp.server.auth.oidc_proxy import OIDCProxy |
26 | 26 | from fastmcp.server.auth.providers.jwt import JWTVerifier |
27 | | -import httpx |
28 | 27 | from key_value.aio.protocols import AsyncKeyValue |
29 | 28 | from key_value.aio.stores.redis import RedisStore |
30 | 29 | from key_value.aio.wrappers.encryption import FernetEncryptionWrapper |
|
33 | 32 | from redis import asyncio as redis_asyncio |
34 | 33 |
|
35 | 34 | _UPSTREAM_OIDC_SCOPES = ('openid', 'profile', 'email', 'offline_access') |
| 35 | + |
| 36 | + |
| 37 | +class _OSMOOIDCProxy(OIDCProxy): |
| 38 | + """OIDC proxy that verifies access tokens against a configured issuer. |
| 39 | +
|
| 40 | + An Entra resource application configured for v1 access tokens issues them |
| 41 | + from ``https://sts.windows.net/<tenant>/`` even when its discovery document |
| 42 | + advertises the v2.0 issuer, and no discovery document can express that. The |
| 43 | + JWKS URI is still taken from discovery. |
| 44 | + """ |
| 45 | + |
| 46 | + def __init__( |
| 47 | + self, |
| 48 | + *, |
| 49 | + access_token_issuer: str, |
| 50 | + access_token_audience: str, |
| 51 | + **kwargs: object, |
| 52 | + ) -> None: |
| 53 | + self._access_token_issuer = access_token_issuer |
| 54 | + self._access_token_audience = access_token_audience |
| 55 | + super().__init__(**kwargs) # type: ignore[arg-type] |
| 56 | + |
| 57 | + def get_token_verifier( # pylint: disable=unused-argument |
| 58 | + self, |
| 59 | + *, |
| 60 | + algorithm: str | None = None, |
| 61 | + audience: str | None = None, |
| 62 | + required_scopes: list[str] | None = None, |
| 63 | + timeout_seconds: int | None = None, |
| 64 | + ) -> JWTVerifier: |
| 65 | + """Build the verifier, keeping the base signature FastMCP calls with. |
| 66 | +
|
| 67 | + ``audience`` and ``timeout_seconds`` are accepted to match the hook |
| 68 | + FastMCP invokes but are not used: the audience comes from OSMO's own |
| 69 | + configuration for the reason below, and JWTVerifier has no timeout. |
| 70 | + """ |
| 71 | + # audience is deliberately not taken from the caller: OIDCProxy's own |
| 72 | + # audience argument is forwarded to the provider's authorize and token |
| 73 | + # endpoints (oidc_proxy.py:432-434), which Entra does not accept. |
| 74 | + return JWTVerifier( |
| 75 | + jwks_uri=str(self.oidc_config.jwks_uri), |
| 76 | + issuer=self._access_token_issuer, |
| 77 | + algorithm=algorithm, |
| 78 | + audience=self._access_token_audience, |
| 79 | + required_scopes=required_scopes, |
| 80 | + ) |
36 | 81 | _REQUIRED_WHEN_AUTH_ENABLED = ( |
37 | 82 | 'resource_url', |
38 | 83 | 'redis_url', |
39 | 84 | 'oidc_config_url', |
40 | 85 | 'oidc_client_id', |
41 | 86 | 'oidc_client_secret_file', |
42 | | - 'oidc_access_token_jwks_url', |
43 | 87 | 'oidc_access_token_issuer', |
44 | 88 | ) |
45 | 89 |
|
@@ -82,10 +126,6 @@ class MCPAuthConfig(pydantic.BaseModel): |
82 | 126 | default=None, |
83 | 127 | json_schema_extra={'env': 'OSMO_MCP_AUTH_OIDC_CLIENT_SECRET_FILE'}, |
84 | 128 | ) |
85 | | - oidc_access_token_jwks_url: str | None = pydantic.Field( |
86 | | - default=None, |
87 | | - json_schema_extra={'env': 'OSMO_MCP_AUTH_OIDC_ACCESS_TOKEN_JWKS_URL'}, |
88 | | - ) |
89 | 129 | oidc_access_token_issuer: str | None = pydantic.Field( |
90 | 130 | default=None, |
91 | 131 | json_schema_extra={'env': 'OSMO_MCP_AUTH_OIDC_ACCESS_TOKEN_ISSUER'}, |
@@ -146,9 +186,6 @@ def _validate_auth_config(self) -> 'MCPAuthConfig': |
146 | 186 | raise ValueError('resource_url must end with /mcp') |
147 | 187 | self.resource_url = resource |
148 | 188 | self.oidc_config_url = _https_url(cast(str, self.oidc_config_url)) |
149 | | - self.oidc_access_token_jwks_url = _https_url( |
150 | | - cast(str, self.oidc_access_token_jwks_url) |
151 | | - ) |
152 | 189 | self.oidc_access_token_issuer = _https_url( |
153 | 190 | cast(str, self.oidc_access_token_issuer), |
154 | 191 | preserve_trailing_slash=True, |
@@ -181,13 +218,9 @@ class MCPAuthRuntime: |
181 | 218 |
|
182 | 219 | provider: OIDCProxy |
183 | 220 | redis_client: redis_asyncio.Redis |
184 | | - http_client: httpx.AsyncClient |
185 | 221 |
|
186 | 222 | async def aclose(self) -> None: |
187 | | - try: |
188 | | - await self.http_client.aclose() |
189 | | - finally: |
190 | | - await self.redis_client.aclose() |
| 223 | + await self.redis_client.aclose() |
191 | 224 |
|
192 | 225 |
|
193 | 226 | def create_auth_runtime(config: MCPAuthConfig) -> MCPAuthRuntime: |
@@ -215,26 +248,16 @@ def create_auth_runtime(config: MCPAuthConfig) -> MCPAuthRuntime: |
215 | 248 | fernet=Fernet(_storage_encryption_key(client_secret)), |
216 | 249 | raise_on_decryption_error=False, |
217 | 250 | ) |
218 | | - http_client = httpx.AsyncClient( |
219 | | - timeout=config.upstream_timeout_seconds, |
220 | | - follow_redirects=False, |
221 | | - ) |
222 | 251 | mcp_url = cast(str, config.resource_url) |
223 | | - verifier = JWTVerifier( |
224 | | - jwks_uri=cast(str, config.oidc_access_token_jwks_url), |
225 | | - issuer=cast(str, config.oidc_access_token_issuer), |
226 | | - audience=mcp_url, |
227 | | - algorithm='RS256', |
228 | | - required_scopes=[config.oidc_access_token_required_scope], |
229 | | - http_client=http_client, |
230 | | - ) |
231 | 252 | requested_scope = config.auth_scope |
232 | 253 | upstream_scope = ' '.join((requested_scope, *_UPSTREAM_OIDC_SCOPES)) |
233 | | - provider = OIDCProxy( |
| 254 | + provider = _OSMOOIDCProxy( |
234 | 255 | config_url=cast(str, config.oidc_config_url), |
235 | 256 | client_id=cast(str, config.oidc_client_id), |
236 | 257 | client_secret=client_secret, |
237 | | - token_verifier=verifier, |
| 258 | + access_token_issuer=cast(str, config.oidc_access_token_issuer), |
| 259 | + access_token_audience=mcp_url, |
| 260 | + required_scopes=[config.oidc_access_token_required_scope], |
238 | 261 | # FastMCP builds its operational OAuth endpoints from base_url and its |
239 | 262 | # RFC 9728 resource identity from resource_base_url plus the MCP path. |
240 | 263 | # Publishing base_url at the MCP URL therefore keeps authorize, token, |
@@ -264,7 +287,7 @@ def create_auth_runtime(config: MCPAuthConfig) -> MCPAuthRuntime: |
264 | 287 | # Entra returns the short `scp` claim that the verifier enforces, while MCP |
265 | 288 | # clients must discover and request the full API scope URI. |
266 | 289 | provider.update_default_scopes([requested_scope]) |
267 | | - return MCPAuthRuntime(provider, redis_client, http_client) |
| 290 | + return MCPAuthRuntime(provider, redis_client) |
268 | 291 |
|
269 | 292 |
|
270 | 293 | def _storage_encryption_key(client_secret: str) -> bytes: |
|
0 commit comments