Skip to content

Commit 6592aaa

Browse files
authored
fix: accept both client_id and identifier_uri as Azure audience (#3797)
1 parent 9f0d8d3 commit 6592aaa

3 files changed

Lines changed: 157 additions & 7 deletions

File tree

src/fastmcp/server/auth/providers/azure.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,7 @@ def __init__(
220220
token_verifier = JWTVerifier(
221221
jwks_uri=jwks_uri,
222222
issuer=issuer,
223-
audience=self.identifier_uri,
223+
audience=[client_id, self.identifier_uri],
224224
algorithm="RS256",
225225
required_scopes=validation_scopes, # Only validate non-OIDC scopes
226226
http_client=http_client,
@@ -627,7 +627,7 @@ def __init__(
627627
super().__init__(
628628
jwks_uri=f"https://{base_authority}/{tenant_id}/discovery/v2.0/keys",
629629
issuer=issuer,
630-
audience=self._identifier_uri,
630+
audience=[client_id, self._identifier_uri],
631631
algorithm="RS256",
632632
required_scopes=required_scopes,
633633
)

tests/server/auth/providers/test_azure.py

Lines changed: 91 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from pydantic import AnyUrl
1010

1111
from fastmcp.server.auth.providers.azure import AzureProvider
12-
from fastmcp.server.auth.providers.jwt import JWTVerifier
12+
from fastmcp.server.auth.providers.jwt import JWTVerifier, RSAKeyPair
1313

1414

1515
@pytest.fixture
@@ -190,8 +190,6 @@ def test_init_with_custom_audience_uses_jwt_verifier(
190190
self, memory_storage: MemoryStore
191191
):
192192
"""When audience is provided, JWTVerifier is configured with JWKS and issuer."""
193-
from fastmcp.server.auth.providers.jwt import JWTVerifier
194-
195193
provider = AzureProvider(
196194
client_id="test_client",
197195
client_secret="test_secret",
@@ -211,11 +209,100 @@ def test_init_with_custom_audience_uses_jwt_verifier(
211209
"https://login.microsoftonline.com/my-tenant/discovery/v2.0/keys"
212210
)
213211
assert verifier.issuer == "https://login.microsoftonline.com/my-tenant/v2.0"
214-
assert verifier.audience == "api://my-api"
212+
assert verifier.audience == ["test_client", "api://my-api"]
215213
# Scopes are stored unprefixed for token validation
216214
# (Azure returns unprefixed scopes like ".default" in JWT tokens)
217215
assert verifier.required_scopes == [".default"]
218216

217+
async def test_token_accepted_with_client_id_audience(
218+
self, memory_storage: MemoryStore
219+
):
220+
"""Azure AD v2 tokens use the bare client_id as aud — must be accepted."""
221+
key_pair = RSAKeyPair.generate()
222+
provider = AzureProvider(
223+
client_id="test_client",
224+
client_secret="test_secret",
225+
tenant_id="my-tenant",
226+
base_url="https://myserver.com",
227+
identifier_uri="api://my-api",
228+
required_scopes=["read"],
229+
jwt_signing_key="test-secret",
230+
client_storage=memory_storage,
231+
)
232+
233+
assert isinstance(provider._token_validator, JWTVerifier)
234+
verifier = provider._token_validator
235+
verifier.public_key = key_pair.public_key
236+
verifier.jwks_uri = None
237+
238+
token = key_pair.create_token(
239+
subject="test-user",
240+
issuer="https://login.microsoftonline.com/my-tenant/v2.0",
241+
audience="test_client",
242+
additional_claims={"scp": "read"},
243+
)
244+
result = await verifier.load_access_token(token)
245+
assert result is not None
246+
247+
async def test_token_accepted_with_identifier_uri_audience(
248+
self, memory_storage: MemoryStore
249+
):
250+
"""Azure AD v1 tokens use the identifier_uri as aud — must be accepted."""
251+
key_pair = RSAKeyPair.generate()
252+
provider = AzureProvider(
253+
client_id="test_client",
254+
client_secret="test_secret",
255+
tenant_id="my-tenant",
256+
base_url="https://myserver.com",
257+
identifier_uri="api://my-api",
258+
required_scopes=["read"],
259+
jwt_signing_key="test-secret",
260+
client_storage=memory_storage,
261+
)
262+
263+
assert isinstance(provider._token_validator, JWTVerifier)
264+
verifier = provider._token_validator
265+
verifier.public_key = key_pair.public_key
266+
verifier.jwks_uri = None
267+
268+
token = key_pair.create_token(
269+
subject="test-user",
270+
issuer="https://login.microsoftonline.com/my-tenant/v2.0",
271+
audience="api://my-api",
272+
additional_claims={"scp": "read"},
273+
)
274+
result = await verifier.load_access_token(token)
275+
assert result is not None
276+
277+
async def test_token_rejected_with_wrong_audience(
278+
self, memory_storage: MemoryStore
279+
):
280+
"""Tokens for a different application must be rejected."""
281+
key_pair = RSAKeyPair.generate()
282+
provider = AzureProvider(
283+
client_id="test_client",
284+
client_secret="test_secret",
285+
tenant_id="my-tenant",
286+
base_url="https://myserver.com",
287+
required_scopes=["read"],
288+
jwt_signing_key="test-secret",
289+
client_storage=memory_storage,
290+
)
291+
292+
assert isinstance(provider._token_validator, JWTVerifier)
293+
verifier = provider._token_validator
294+
verifier.public_key = key_pair.public_key
295+
verifier.jwks_uri = None
296+
297+
token = key_pair.create_token(
298+
subject="test-user",
299+
issuer="https://login.microsoftonline.com/my-tenant/v2.0",
300+
audience="wrong-app-id",
301+
additional_claims={"scp": "read"},
302+
)
303+
result = await verifier.load_access_token(token)
304+
assert result is None
305+
219306
async def test_authorize_filters_resource_and_stores_unprefixed_scopes(
220307
self, memory_storage: MemoryStore
221308
):

tests/server/auth/providers/test_azure_scopes.py

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -413,7 +413,7 @@ def test_auto_configures_from_client_and_tenant(self):
413413
== "https://login.microsoftonline.com/my-tenant-id/discovery/v2.0/keys"
414414
)
415415
assert verifier.issuer == "https://login.microsoftonline.com/my-tenant-id/v2.0"
416-
assert verifier.audience == "api://my-client-id"
416+
assert verifier.audience == ["my-client-id", "api://my-client-id"]
417417
assert verifier.algorithm == "RS256"
418418
assert verifier.required_scopes == ["access_as_user"]
419419

@@ -438,6 +438,69 @@ async def test_validates_short_form_scopes(self):
438438
assert result is not None
439439
assert "access_as_user" in result.scopes
440440

441+
async def test_validates_token_with_client_id_audience(self):
442+
"""Azure AD v2 tokens use the bare client_id GUID as audience."""
443+
key_pair = RSAKeyPair.generate()
444+
verifier = AzureJWTVerifier(
445+
client_id="my-client-id",
446+
tenant_id="my-tenant-id",
447+
required_scopes=["access_as_user"],
448+
)
449+
verifier.public_key = key_pair.public_key
450+
verifier.jwks_uri = None
451+
452+
token = key_pair.create_token(
453+
subject="test-user",
454+
issuer="https://login.microsoftonline.com/my-tenant-id/v2.0",
455+
audience="my-client-id",
456+
additional_claims={"scp": "access_as_user"},
457+
)
458+
result = await verifier.load_access_token(token)
459+
assert result is not None
460+
assert "access_as_user" in result.scopes
461+
462+
async def test_validates_token_with_custom_identifier_uri_audience(self):
463+
"""Custom identifier_uri (e.g. Bicep deployments) accepted as audience."""
464+
key_pair = RSAKeyPair.generate()
465+
verifier = AzureJWTVerifier(
466+
client_id="my-client-id",
467+
tenant_id="my-tenant-id",
468+
required_scopes=["read"],
469+
identifier_uri="api://my-app-name",
470+
)
471+
verifier.public_key = key_pair.public_key
472+
verifier.jwks_uri = None
473+
474+
token = key_pair.create_token(
475+
subject="test-user",
476+
issuer="https://login.microsoftonline.com/my-tenant-id/v2.0",
477+
audience="api://my-app-name",
478+
additional_claims={"scp": "read"},
479+
)
480+
result = await verifier.load_access_token(token)
481+
assert result is not None
482+
assert "read" in result.scopes
483+
484+
async def test_rejects_token_with_wrong_audience(self):
485+
"""Tokens for a different application must be rejected."""
486+
key_pair = RSAKeyPair.generate()
487+
verifier = AzureJWTVerifier(
488+
client_id="my-client-id",
489+
tenant_id="my-tenant-id",
490+
required_scopes=["read"],
491+
)
492+
verifier.public_key = key_pair.public_key
493+
verifier.jwks_uri = None
494+
495+
token = key_pair.create_token(
496+
subject="test-user",
497+
issuer="https://login.microsoftonline.com/my-tenant-id/v2.0",
498+
audience="some-other-app-id",
499+
additional_claims={"scp": "read"},
500+
)
501+
result = await verifier.load_access_token(token)
502+
assert result is None
503+
441504
def test_scopes_supported_returns_prefixed_form(self):
442505
verifier = AzureJWTVerifier(
443506
client_id="my-client-id",

0 commit comments

Comments
 (0)