Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions fastmcp_slim/fastmcp/server/auth/providers/azure.py
Original file line number Diff line number Diff line change
Expand Up @@ -501,8 +501,12 @@ def _prepare_scopes_for_token_exchange(self, scopes: list[str]) -> list[str]:
Returns:
List of scopes for Azure token endpoint
"""
# Prefix scopes for this API
prefixed_scopes = self._prefix_scopes_for_azure(scopes or [])
# Prefix scopes for this API. Some clients omit the scope parameter on
# the MCP authorization request; use the provider's configured scopes
# just like the authorize URL path does.
prefixed_scopes = self._prefix_scopes_for_azure(
scopes or self.required_scopes or []
)

# Add OIDC scopes only (not other API scopes) to avoid AADSTS28000
if self.additional_authorize_scopes:
Expand All @@ -528,9 +532,13 @@ def _prepare_scopes_for_upstream_refresh(self, scopes: list[str]) -> list[str]:
"""
logger.debug("Base scopes from storage: %s", scopes)

# Some clients omit the scope parameter on the MCP authorization request;
# use the provider's configured scopes just like the authorize URL path does.
requested_scopes = scopes or self.required_scopes or []

# Filter out any additional_authorize_scopes that may have been stored
additional_scopes_set = set(self.additional_authorize_scopes or [])
base_scopes = [s for s in scopes if s not in additional_scopes_set]
base_scopes = [s for s in requested_scopes if s not in additional_scopes_set]

# Prefix base scopes with identifier_uri for Azure
prefixed_scopes = self._prefix_scopes_for_azure(base_scopes)
Expand Down
27 changes: 23 additions & 4 deletions tests/server/auth/providers/test_azure.py
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,25 @@ async def test_authorize_appends_additional_scopes(
assert "Mail.Read" in upstream_url
assert "User.Read" in upstream_url

def test_prepare_scopes_for_token_exchange_falls_back_to_required_scopes(
self, memory_storage: MemoryStore
):
"""Clients may omit scope; Azure still needs an API scope with offline_access."""
provider = AzureProvider(
client_id="test_client",
client_secret="test_secret",
tenant_id="test-tenant",
base_url="https://myserver.com",
identifier_uri="api://my-api",
required_scopes=["read"],
jwt_signing_key="test-secret",
client_storage=memory_storage,
)

result = provider._prepare_scopes_for_token_exchange([])

assert result == ["api://my-api/read", "offline_access"]

def test_base_authority_defaults_to_public_cloud(self, memory_storage: MemoryStore):
"""Test that base_authority defaults to login.microsoftonline.com."""
provider = AzureProvider(
Expand Down Expand Up @@ -724,10 +743,10 @@ def test_prepare_scopes_for_upstream_refresh_scope_with_slash(
"https://graph.microsoft.com/.default" in result
) # Not prefixed (contains ://)

def test_prepare_scopes_for_upstream_refresh_empty_scopes(
def test_prepare_scopes_for_upstream_refresh_empty_scopes_falls_back_to_required(
self, memory_storage: MemoryStore
):
"""Test behavior with empty scopes list."""
"""Clients may omit scope; refresh should still request the configured API scope."""
provider = AzureProvider(
client_id="test_client",
client_secret="test_secret",
Expand All @@ -740,13 +759,13 @@ def test_prepare_scopes_for_upstream_refresh_empty_scopes(
client_storage=memory_storage,
)

# Empty scopes should still add OIDC scopes (not User.Read)
result = provider._prepare_scopes_for_upstream_refresh([])

assert "api://my-api/read" in result
assert "User.Read" not in result # Not OIDC
assert "openid" in result
assert "offline_access" in result # Auto-included
assert len(result) == 2 # Only OIDC scopes: openid + offline_access
assert len(result) == 3

def test_prepare_scopes_for_upstream_refresh_no_additional_scopes(
self, memory_storage: MemoryStore
Expand Down