Skip to content

Commit 223664a

Browse files
jiaenrenclaude
andcommitted
Take the MCP token-verification JWKS URI from OIDC discovery
The deployer supplied accessTokenJwksUrl, which every OIDC discovery document already publishes as jwks_uri and which FastMCP already fetches at startup. OSMO hand-built a JWTVerifier only so it could pin that URL, and carried a process-lifetime httpx client to serve it. Let OIDCProxy build the verifier and override only the piece discovery cannot supply. An Entra resource application configured for v1 access tokens issues them from https://sts.windows.net/<tenant>/ even when its discovery document advertises the v2.0 issuer, so accessTokenIssuer stays a configured value; the JWKS URI now comes from discovery. The audience is applied inside the override rather than through OIDCProxy's own audience argument, because that argument is forwarded to the provider's authorize and token endpoints (oidc_proxy.py:432-434), which Entra does not accept. An existing test asserting that no extra token parameters are sent caught this. Drops the accessTokenJwksUrl value, its environment variable, its two Helm validations, the hand-built verifier, the httpx client, and the try/finally that existed to close it. Required OIDC proxy values: 7 -> 6. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 13141f1 commit 223664a

9 files changed

Lines changed: 67 additions & 61 deletions

File tree

deployments/charts/service/README.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,6 @@ destination, but it cannot validate external DNS.
109109
| `services.mcp.oidcProxy.oidc.clientId` | Administrator-managed confidential OIDC application client ID. | `""` |
110110
| `services.mcp.oidcProxy.oidc.clientSecretFile` | Mounted file containing the upstream OIDC client secret. | `/etc/osmo/mcp-auth/client-secret` |
111111
| `services.mcp.oidcProxy.oidc.accessTokenIssuer` | Exact issuer required on upstream API access tokens. | `""` |
112-
| `services.mcp.oidcProxy.oidc.accessTokenJwksUrl` | HTTPS JWKS URL used to verify upstream API access tokens. | `""` |
113112
| `services.mcp.oidcProxy.oidc.accessTokenRequiredScope` | Short scope value required in the upstream access token's `scp` claim. | `access_as_user` |
114113
| `services.mcp.oidcProxy.redis` | Redis connection used by FastMCP for registrations, authorization state, and encrypted upstream tokens; blank host/port inherit `services.redis`. | See `values.yaml` |
115114
| `services.mcp.oidcProxy.accessTokenTtlSeconds` | Lifetime of proxy access tokens, from 60 through 3600 seconds. | `600` |

deployments/charts/service/ci/mcp-oidc-proxy-values.yaml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@ services:
2727
configUrl: https://login.example.com/example-tenant/v2.0/.well-known/openid-configuration
2828
clientId: example-mcp-proxy-client
2929
accessTokenIssuer: https://sts.example.com/example-tenant/
30-
accessTokenJwksUrl: https://login.example.com/example-tenant/discovery/v2.0/keys
3130
accessTokenRequiredScope: access_as_user
3231
redis:
3332
serviceName: proxy-redis.example.internal

deployments/charts/service/ci/validate-mcp-chart.sh

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,6 @@ for expected in \
170170
'name: OSMO_MCP_AUTH_OIDC_CLIENT_ID' \
171171
'name: OSMO_MCP_AUTH_OIDC_CLIENT_SECRET_FILE' \
172172
'name: OSMO_MCP_AUTH_OIDC_ACCESS_TOKEN_ISSUER' \
173-
'name: OSMO_MCP_AUTH_OIDC_ACCESS_TOKEN_JWKS_URL' \
174173
'name: OSMO_MCP_AUTH_OIDC_ACCESS_TOKEN_REQUIRED_SCOPE' \
175174
'name: OSMO_MCP_AUTH_UPSTREAM_TIMEOUT_SECONDS' \
176175
'secretName: mcp-oidc-proxy-secrets'; do

deployments/charts/service/templates/mcp-service.yaml

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@
2929
{{- $oidcClientId := "" }}
3030
{{- $oidcClientSecretFile := "" }}
3131
{{- $oidcAccessTokenIssuer := "" }}
32-
{{- $oidcAccessTokenJwksUrl := "" }}
3332
{{- $oidcAccessTokenRequiredScope := "" }}
3433
{{- $redisKeyPrefix := "" }}
3534
{{- $redisUrl := "" }}
@@ -41,7 +40,6 @@
4140
{{- $oidcClientId = required "services.mcp.oidcProxy.oidc.clientId is required when the OIDC proxy is enabled" $oidcProxy.oidc.clientId }}
4241
{{- $oidcClientSecretFile = required "services.mcp.oidcProxy.oidc.clientSecretFile is required when the OIDC proxy is enabled" $oidcProxy.oidc.clientSecretFile }}
4342
{{- $oidcAccessTokenIssuer = required "services.mcp.oidcProxy.oidc.accessTokenIssuer is required when the OIDC proxy is enabled" $oidcProxy.oidc.accessTokenIssuer }}
44-
{{- $oidcAccessTokenJwksUrl = required "services.mcp.oidcProxy.oidc.accessTokenJwksUrl is required when the OIDC proxy is enabled" $oidcProxy.oidc.accessTokenJwksUrl }}
4543
{{- $oidcAccessTokenRequiredScope = required "services.mcp.oidcProxy.oidc.accessTokenRequiredScope is required when the OIDC proxy is enabled" $oidcProxy.oidc.accessTokenRequiredScope }}
4644
{{- if not (regexMatch "^[A-Za-z0-9:._~-]{1,128}$" $oidcAccessTokenRequiredScope) }}
4745
{{- fail "services.mcp.oidcProxy.oidc.accessTokenRequiredScope must be one non-empty scope" }}
@@ -53,10 +51,7 @@
5351
{{- if not (regexMatch $oidcHttpsUrlPattern $oidcAccessTokenIssuer) }}
5452
{{- fail "services.mcp.oidcProxy.oidc.accessTokenIssuer must be an absolute HTTPS issuer without query or fragment" }}
5553
{{- end }}
56-
{{- if not (regexMatch $oidcHttpsUrlPattern $oidcAccessTokenJwksUrl) }}
57-
{{- fail "services.mcp.oidcProxy.oidc.accessTokenJwksUrl must be an absolute HTTPS URL without query or fragment" }}
58-
{{- end }}
59-
{{- range $name, $url := dict "configUrl" $oidcConfigUrl "accessTokenIssuer" $oidcAccessTokenIssuer "accessTokenJwksUrl" $oidcAccessTokenJwksUrl }}
54+
{{- range $name, $url := dict "configUrl" $oidcConfigUrl "accessTokenIssuer" $oidcAccessTokenIssuer }}
6055
{{- $portMatch := regexFind ":[0-9]+(/|$)" $url }}
6156
{{- if $portMatch }}
6257
{{- $urlPort := trimSuffix "/" (trimPrefix ":" $portMatch) | int }}
@@ -146,7 +141,6 @@
146141
"OSMO_MCP_AUTH_OIDC_CLIENT_ID"
147142
"OSMO_MCP_AUTH_OIDC_CLIENT_SECRET_FILE"
148143
"OSMO_MCP_AUTH_OIDC_ACCESS_TOKEN_ISSUER"
149-
"OSMO_MCP_AUTH_OIDC_ACCESS_TOKEN_JWKS_URL"
150144
"OSMO_MCP_AUTH_OIDC_ACCESS_TOKEN_REQUIRED_SCOPE"
151145
"OSMO_MCP_AUTH_ACCESS_TOKEN_TTL_SECONDS"
152146
"OSMO_MCP_AUTH_REFRESH_TOKEN_TTL_SECONDS"
@@ -248,8 +242,6 @@ spec:
248242
value: {{ $oidcClientSecretFile | quote }}
249243
- name: OSMO_MCP_AUTH_OIDC_ACCESS_TOKEN_ISSUER
250244
value: {{ $oidcAccessTokenIssuer | quote }}
251-
- name: OSMO_MCP_AUTH_OIDC_ACCESS_TOKEN_JWKS_URL
252-
value: {{ $oidcAccessTokenJwksUrl | quote }}
253245
- name: OSMO_MCP_AUTH_OIDC_ACCESS_TOKEN_REQUIRED_SCOPE
254246
value: {{ $oidcAccessTokenRequiredScope | quote }}
255247
- name: OSMO_MCP_AUTH_ACCESS_TOKEN_TTL_SECONDS

deployments/charts/service/values.yaml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,6 @@ services:
184184
clientId: ""
185185
clientSecretFile: /etc/osmo/mcp-auth/client-secret
186186
accessTokenIssuer: ""
187-
accessTokenJwksUrl: ""
188187
accessTokenRequiredScope: access_as_user
189188

190189
## Redis stores proxy registrations, authorization state, and encrypted

docs/deployment_guide/advanced_config/mcp.rst

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,6 @@ to the deployment:
185185
clientId: <confidential-oidc-client-id>
186186
clientSecretFile: /etc/osmo/mcp-auth/client-secret
187187
accessTokenIssuer: https://issuer.example.com/
188-
accessTokenJwksUrl: https://idp.example.com/jwks
189188
accessTokenRequiredScope: access_as_user
190189
redis:
191190
dbNumber: 0

src/service/mcp/README.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,6 @@ services:
222222
clientId: <oidc-application-client-id>
223223
clientSecretFile: /etc/osmo/mcp-auth/client-secret
224224
accessTokenIssuer: https://sts.windows.net/<tenant-id>/
225-
accessTokenJwksUrl: https://login.microsoftonline.com/<tenant-id>/discovery/v2.0/keys
226225
accessTokenRequiredScope: access_as_user
227226
redis:
228227
dbNumber: <dedicated-database-number>

src/service/mcp/auth.py

Lines changed: 52 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@
2424
from fastmcp.server.auth.jwt_issuer import derive_jwt_key
2525
from fastmcp.server.auth.oidc_proxy import OIDCProxy
2626
from fastmcp.server.auth.providers.jwt import JWTVerifier
27-
import httpx
2827
from key_value.aio.protocols import AsyncKeyValue
2928
from key_value.aio.stores.redis import RedisStore
3029
from key_value.aio.wrappers.encryption import FernetEncryptionWrapper
@@ -33,13 +32,58 @@
3332
from redis import asyncio as redis_asyncio
3433

3534
_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+
)
3681
_REQUIRED_WHEN_AUTH_ENABLED = (
3782
'resource_url',
3883
'redis_url',
3984
'oidc_config_url',
4085
'oidc_client_id',
4186
'oidc_client_secret_file',
42-
'oidc_access_token_jwks_url',
4387
'oidc_access_token_issuer',
4488
)
4589

@@ -82,10 +126,6 @@ class MCPAuthConfig(pydantic.BaseModel):
82126
default=None,
83127
json_schema_extra={'env': 'OSMO_MCP_AUTH_OIDC_CLIENT_SECRET_FILE'},
84128
)
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-
)
89129
oidc_access_token_issuer: str | None = pydantic.Field(
90130
default=None,
91131
json_schema_extra={'env': 'OSMO_MCP_AUTH_OIDC_ACCESS_TOKEN_ISSUER'},
@@ -146,9 +186,6 @@ def _validate_auth_config(self) -> 'MCPAuthConfig':
146186
raise ValueError('resource_url must end with /mcp')
147187
self.resource_url = resource
148188
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-
)
152189
self.oidc_access_token_issuer = _https_url(
153190
cast(str, self.oidc_access_token_issuer),
154191
preserve_trailing_slash=True,
@@ -181,13 +218,9 @@ class MCPAuthRuntime:
181218

182219
provider: OIDCProxy
183220
redis_client: redis_asyncio.Redis
184-
http_client: httpx.AsyncClient
185221

186222
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()
191224

192225

193226
def create_auth_runtime(config: MCPAuthConfig) -> MCPAuthRuntime:
@@ -215,26 +248,16 @@ def create_auth_runtime(config: MCPAuthConfig) -> MCPAuthRuntime:
215248
fernet=Fernet(_storage_encryption_key(client_secret)),
216249
raise_on_decryption_error=False,
217250
)
218-
http_client = httpx.AsyncClient(
219-
timeout=config.upstream_timeout_seconds,
220-
follow_redirects=False,
221-
)
222251
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-
)
231252
requested_scope = config.auth_scope
232253
upstream_scope = ' '.join((requested_scope, *_UPSTREAM_OIDC_SCOPES))
233-
provider = OIDCProxy(
254+
provider = _OSMOOIDCProxy(
234255
config_url=cast(str, config.oidc_config_url),
235256
client_id=cast(str, config.oidc_client_id),
236257
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],
238261
# FastMCP builds its operational OAuth endpoints from base_url and its
239262
# RFC 9728 resource identity from resource_base_url plus the MCP path.
240263
# Publishing base_url at the MCP URL therefore keeps authorize, token,
@@ -264,7 +287,7 @@ def create_auth_runtime(config: MCPAuthConfig) -> MCPAuthRuntime:
264287
# Entra returns the short `scp` claim that the verifier enforces, while MCP
265288
# clients must discover and request the full API scope URI.
266289
provider.update_default_scopes([requested_scope])
267-
return MCPAuthRuntime(provider, redis_client, http_client)
290+
return MCPAuthRuntime(provider, redis_client)
268291

269292

270293
def _storage_encryption_key(client_secret: str) -> bytes:

src/service/mcp/tests/test_auth.py

Lines changed: 14 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from unittest import mock
2424

2525
from fastmcp.server.auth.oidc_proxy import OIDCConfiguration, OIDCProxy
26+
from fastmcp.server.auth.providers.jwt import JWTVerifier
2627
from fastmcp.server.auth.oauth_proxy.models import UpstreamTokenSet
2728
import httpx
2829
from key_value.aio.stores.memory import MemoryStore
@@ -147,14 +148,26 @@ async def test_factory_uses_plain_oidc_proxy_and_split_scope_contract(self) -> N
147148

148149
try:
149150
provider = runtime.provider
150-
self.assertIs(type(provider), OIDCProxy)
151+
# The subclass exists only to keep the configured access-token
152+
# issuer; everything else is stock OIDCProxy behaviour.
153+
self.assertIsInstance(provider, OIDCProxy)
151154
self.assertEqual(
152155
provider._jwt_signing_key, # pylint: disable=protected-access
153156
auth.derive_jwt_key(
154157
high_entropy_material='client-secret',
155158
salt='fastmcp-jwt-signing-key',
156159
),
157160
)
161+
# The JWKS URI comes from the discovery document; only the
162+
# access-token issuer, which no discovery document can supply
163+
# for an Entra v1 resource app, stays configured.
164+
verifier = provider._token_validator # pylint: disable=protected-access
165+
assert isinstance(verifier, JWTVerifier)
166+
self.assertEqual(
167+
verifier.jwks_uri,
168+
'https://login.example/tenant/discovery/v2.0/keys',
169+
)
170+
self.assertEqual(verifier.issuer, 'https://sts.example/tenant/')
158171
self.assertEqual(provider.required_scopes, ['access_as_user'])
159172
self.assertEqual(
160173
provider._token_validator.required_scopes, # pylint: disable=protected-access
@@ -316,22 +329,6 @@ async def __aexit__(self, *args: object) -> None:
316329
await runtime.aclose()
317330
redis_client.aclose.assert_awaited_once()
318331

319-
async def test_close_releases_redis_when_http_close_fails(self) -> None:
320-
provider = mock.create_autospec(OIDCProxy, instance=True)
321-
redis_client = mock.create_autospec(
322-
auth.redis_asyncio.Redis,
323-
instance=True,
324-
)
325-
http_client = mock.create_autospec(httpx.AsyncClient, instance=True)
326-
http_client.aclose.side_effect = RuntimeError('HTTP close failed')
327-
runtime = auth.MCPAuthRuntime(provider, redis_client, http_client)
328-
329-
with self.assertRaisesRegex(RuntimeError, 'HTTP close failed'):
330-
await runtime.aclose()
331-
332-
http_client.aclose.assert_awaited_once_with()
333-
redis_client.aclose.assert_awaited_once_with()
334-
335332
def test_storage_key_matches_fastmcp_default_and_is_deterministic(self) -> None:
336333
first = auth._storage_encryption_key( # pylint: disable=protected-access
337334
'client-secret',

0 commit comments

Comments
 (0)