Skip to content

Commit db09914

Browse files
author
Olivier Gintrand
committed
fix(oauth): accept api://{resource_id}/{perm} scopes for audience validation
Signed-off-by: Olivier Gintrand <olivier.gintrand@forterro.com>
1 parent a02a04b commit db09914

File tree

2 files changed

+164
-7
lines changed

2 files changed

+164
-7
lines changed

mcpgateway/services/token_validation_service.py

Lines changed: 74 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,43 @@ def _validate_audience(claims: Dict[str, Any], oauth_config: Dict[str, Any], gat
162162
return
163163

164164
aud_list = token_aud if isinstance(token_aud, list) else [token_aud]
165-
if expected_audience in aud_list:
165+
166+
# Build set of acceptable audiences:
167+
# 1. The configured resource or gateway URL
168+
# 2. The OAuth client_id (Azure AD v1 tokens use client_id as audience
169+
# when scopes are requested with {client_id}/.default)
170+
# 3. api://{client_id} (Azure AD app ID URI convention)
171+
# 4. Resource IDs derived from {resource_id}/.default scope patterns
172+
# (Azure AD issues tokens with aud={resource_id} for these scopes)
173+
acceptable = {expected_audience}
174+
client_id = oauth_config.get("client_id")
175+
if client_id:
176+
acceptable.add(client_id)
177+
acceptable.add(f"api://{client_id}")
178+
179+
# Extract resource IDs from Azure AD scope patterns.
180+
# Pattern 1: "{resource_id}/.default" — token aud = resource_id
181+
# Pattern 2: "api://{resource_id}/{permission}" — token aud = resource_id
182+
# In both cases Entra ID issues the token with aud set to the
183+
# resource application's client_id (a GUID), not the URI.
184+
for scope in oauth_config.get("scopes", []):
185+
if not isinstance(scope, str):
186+
continue
187+
if scope.endswith("/.default"):
188+
resource_id = scope.removesuffix("/.default")
189+
if resource_id:
190+
acceptable.add(resource_id)
191+
acceptable.add(f"api://{resource_id}")
192+
elif scope.startswith("api://"):
193+
# api://{resource_id}/{permission} — extract the resource_id
194+
without_scheme = scope[len("api://"):]
195+
slash_pos = without_scheme.find("/")
196+
if slash_pos > 0:
197+
resource_id = without_scheme[:slash_pos]
198+
acceptable.add(resource_id)
199+
acceptable.add(f"api://{resource_id}")
200+
201+
if any(a in acceptable for a in aud_list):
166202
result.audience_match = True
167203
else:
168204
result.audience_match = False
@@ -184,6 +220,11 @@ def _validate_scopes(claims: Dict[str, Any], oauth_config: Dict[str, Any], gatew
184220
if not configured_scopes:
185221
return
186222

223+
# OIDC meta-scopes control the authorization request behavior but never
224+
# appear in the access token's scope/scp claim. Exclude them from the
225+
# validation check to avoid false-positive "missing scope" errors.
226+
_OIDC_META_SCOPES = {"openid", "offline_access", "profile", "email"}
227+
187228
# Entra ID uses 'scp' claim; standard OAuth uses 'scope'
188229
token_scope_str = claims.get("scope") or claims.get("scp") or ""
189230
if not token_scope_str:
@@ -193,6 +234,14 @@ def _validate_scopes(claims: Dict[str, Any], oauth_config: Dict[str, Any], gatew
193234
granted_scopes = _normalize_scope(token_scope_str)
194235
missing = []
195236
for cfg_scope in configured_scopes:
237+
if cfg_scope.lower() in _OIDC_META_SCOPES:
238+
continue
239+
# Azure AD /.default is a request-time directive meaning "all
240+
# configured permissions for this app". The actual granted
241+
# permissions appear individually in the scp claim, never as
242+
# "{client_id}/.default". Skip it.
243+
if cfg_scope.endswith("/.default"):
244+
continue
196245
short = cfg_scope.rsplit("/", 1)[-1] if "/" in cfg_scope else cfg_scope
197246
if cfg_scope not in granted_scopes and short not in granted_scopes:
198247
missing.append(cfg_scope)
@@ -223,13 +272,31 @@ def _validate_issuer(claims: Dict[str, Any], oauth_config: Dict[str, Any], gatew
223272
logger.debug("OAuth token for gateway %s has no 'iss' claim", gateway_name)
224273
return
225274

226-
if token_iss.rstrip("/") == expected_issuer.rstrip("/"):
275+
# Normalize both to compare without trailing slashes
276+
norm_iss = token_iss.rstrip("/")
277+
norm_expected = expected_issuer.rstrip("/")
278+
279+
if norm_iss == norm_expected:
227280
result.issuer_match = True
228-
else:
229-
result.issuer_match = False
230-
safe_iss = str(token_iss)[:80]
231-
safe_expected = str(expected_issuer)[:80]
232-
result.warnings.append(f"Token issuer mismatch: token iss='{safe_iss}', expected '{safe_expected}'")
281+
return
282+
283+
# Azure AD v1/v2 duality: v1 tokens use iss=https://sts.windows.net/{tenant}
284+
# while v2 tokens use iss=https://login.microsoftonline.com/{tenant}/v2.0.
285+
# Both are valid for the same tenant — accept either when the tenant matches.
286+
entra_v1_prefix = "https://sts.windows.net/"
287+
entra_v2_prefix = "https://login.microsoftonline.com/"
288+
v1_tenant = norm_iss.removeprefix(entra_v1_prefix) if norm_iss.startswith(entra_v1_prefix) else None
289+
v2_tenant = norm_expected.removeprefix(entra_v2_prefix).removesuffix("/v2.0") if norm_expected.startswith(entra_v2_prefix) else None
290+
291+
if v1_tenant and v2_tenant and v1_tenant == v2_tenant:
292+
result.issuer_match = True
293+
logger.debug("Azure AD v1/v2 issuer duality accepted for gateway %s (tenant %s)", gateway_name, v1_tenant)
294+
return
295+
296+
result.issuer_match = False
297+
safe_iss = str(token_iss)[:80]
298+
safe_expected = str(expected_issuer)[:80]
299+
result.warnings.append(f"Token issuer mismatch: token iss='{safe_iss}', expected '{safe_expected}'")
233300

234301

235302
def validate_oauth_token_claims(

tests/unit/mcpgateway/services/test_token_validation_service.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,3 +366,93 @@ def test_none_values_in_oauth_config(self):
366366

367367
# Should not crash
368368
assert result.is_jwt is True
369+
370+
# -- Azure AD / Entra ID specific cases --
371+
372+
def test_audience_match_client_id_as_aud(self):
373+
"""Azure AD v1 tokens use client_id as audience with {client_id}/.default."""
374+
token = _make_jwt({"aud": "8443cbba-a854-4d4b-801a-87bb20e312ce"})
375+
oauth_config = {
376+
"client_id": "8443cbba-a854-4d4b-801a-87bb20e312ce",
377+
"resource": "https://mcp-server.example.com",
378+
}
379+
result = validate_oauth_token_claims(token, oauth_config, "https://gw.example.com", "test-gw")
380+
381+
assert result.audience_match is True
382+
383+
def test_audience_match_api_prefix_client_id(self):
384+
"""Azure AD app ID URI convention: api://{client_id}."""
385+
token = _make_jwt({"aud": "api://8443cbba-a854-4d4b-801a-87bb20e312ce"})
386+
oauth_config = {
387+
"client_id": "8443cbba-a854-4d4b-801a-87bb20e312ce",
388+
"resource": "https://mcp-server.example.com",
389+
}
390+
result = validate_oauth_token_claims(token, oauth_config, "https://gw.example.com", "test-gw")
391+
392+
assert result.audience_match is True
393+
394+
def test_audience_match_resource_id_from_default_scope(self):
395+
"""Audience derived from {resource_id}/.default scope pattern (Agent365)."""
396+
token = _make_jwt({"aud": "ea9ffc3e-8a23-4a7d-836d-234d7c7565c1"})
397+
oauth_config = {
398+
"client_id": "8443cbba-a854-4d4b-801a-87bb20e312ce",
399+
"scopes": ["ea9ffc3e-8a23-4a7d-836d-234d7c7565c1/.default", "openid", "offline_access"],
400+
}
401+
result = validate_oauth_token_claims(token, oauth_config, "https://agent365.svc.cloud.microsoft/mcp", "m365-mail")
402+
403+
assert result.audience_match is True
404+
assert not any("audience" in w.lower() for w in result.warnings)
405+
406+
def test_audience_match_api_prefix_resource_id_from_default_scope(self):
407+
"""Audience api://{resource_id} derived from {resource_id}/.default scope."""
408+
token = _make_jwt({"aud": "api://ea9ffc3e-8a23-4a7d-836d-234d7c7565c1"})
409+
oauth_config = {
410+
"client_id": "8443cbba-a854-4d4b-801a-87bb20e312ce",
411+
"scopes": ["ea9ffc3e-8a23-4a7d-836d-234d7c7565c1/.default"],
412+
}
413+
result = validate_oauth_token_claims(token, oauth_config, "https://gw.example.com", "test-gw")
414+
415+
assert result.audience_match is True
416+
417+
def test_audience_mismatch_different_resource_id(self):
418+
"""Token audience doesn't match any known resource — still blocked."""
419+
token = _make_jwt({"aud": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"})
420+
oauth_config = {
421+
"client_id": "8443cbba-a854-4d4b-801a-87bb20e312ce",
422+
"scopes": ["ea9ffc3e-8a23-4a7d-836d-234d7c7565c1/.default"],
423+
}
424+
result = validate_oauth_token_claims(token, oauth_config, "https://gw.example.com", "test-gw")
425+
426+
assert result.audience_match is False
427+
428+
def test_issuer_entra_v1_v2_duality(self):
429+
"""Azure AD v1 issuer (sts.windows.net) matches v2 expected issuer for same tenant."""
430+
tenant = "7dd3b0c4-aa40-4ba6-b77d-c2f711e1bc2a"
431+
token = _make_jwt({"iss": f"https://sts.windows.net/{tenant}/"})
432+
oauth_config = {
433+
"token_url": f"https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token",
434+
}
435+
result = validate_oauth_token_claims(token, oauth_config, "https://gw.example.com", "test-gw")
436+
437+
assert result.issuer_match is True
438+
439+
def test_scopes_oidc_meta_scopes_excluded(self):
440+
"""OIDC meta-scopes (openid, offline_access, etc.) are excluded from validation."""
441+
token = _make_jwt({"scp": "Mail.Read Mail.Send"})
442+
oauth_config = {
443+
"scopes": ["ea9ffc3e/.default", "openid", "offline_access", "profile", "email"],
444+
}
445+
result = validate_oauth_token_claims(token, oauth_config, "https://gw.example.com", "test-gw")
446+
447+
# The /.default scope is also skipped; meta-scopes are excluded
448+
assert result.scopes_sufficient is True
449+
450+
def test_scopes_default_scope_excluded(self):
451+
"""Azure AD /.default is a request-time directive, not a real scope."""
452+
token = _make_jwt({"scp": "Mail.Read"})
453+
oauth_config = {
454+
"scopes": ["8443cbba-a854-4d4b-801a-87bb20e312ce/.default"],
455+
}
456+
result = validate_oauth_token_claims(token, oauth_config, "https://gw.example.com", "test-gw")
457+
458+
assert result.scopes_sufficient is True

0 commit comments

Comments
 (0)