Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 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
36 changes: 36 additions & 0 deletions src/fastmcp/server/auth/providers/azure.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
from __future__ import annotations

import httpx
from mcp.server.auth.provider import AuthorizationParams
from mcp.shared.auth import OAuthClientInformationFull
from pydantic import SecretStr, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict

Expand Down Expand Up @@ -258,3 +260,37 @@ def __init__(
settings.client_id,
tenant_id_final,
)

async def authorize(
self,
client: OAuthClientInformationFull,
params: AuthorizationParams,
) -> str:
"""Start OAuth transaction and redirect to Azure AD.

Override parent's authorize method to filter out the 'resource' parameter
which is not supported by Azure AD v2.0 endpoints. The v2.0 endpoints use
scopes to determine the resource/audience instead of a separate parameter.

Args:
client: OAuth client information
params: Authorization parameters from the client

Returns:
Authorization URL to redirect the user to Azure AD
"""
# Clear the resource parameter that Azure AD v2.0 doesn't support
# This parameter comes from RFC 8707 (OAuth 2.0 Resource Indicators)
# but Azure AD v2.0 uses scopes instead to determine the audience
if hasattr(params, "resource"):
original_resource = params.resource
params.resource = None

if original_resource:
logger.debug(
"Filtering out 'resource' parameter '%s' for Azure AD v2.0 (use scopes instead)",
original_resource,
)

# Call parent's authorize method with the filtered parameters
return await super().authorize(client, params)
116 changes: 115 additions & 1 deletion tests/server/auth/providers/test_azure.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
"""Tests for Azure (Microsoft Entra) OAuth provider."""

import os
from unittest.mock import patch
from unittest.mock import AsyncMock, patch
from urllib.parse import urlparse

import pytest
from mcp.server.auth.provider import AuthorizationParams
from mcp.shared.auth import OAuthClientInformationFull
from pydantic import AnyUrl

from fastmcp.server.auth.oauth_proxy import OAuthProxy
from fastmcp.server.auth.providers.azure import AzureProvider


Expand Down Expand Up @@ -162,3 +166,113 @@ def test_azure_specific_scopes(self):

# Provider should initialize successfully with these scopes
assert provider is not None

async def test_authorize_filters_resource_parameter(self):
"""Test that authorize method filters out the 'resource' parameter for Azure AD v2.0."""

provider = AzureProvider(
client_id="test_client",
client_secret="test_secret",
tenant_id="test-tenant",
base_url="https://myserver.com",
)

# Create a mock client
client = OAuthClientInformationFull(
client_id="test_client_123",
client_secret="client_secret_456",
redirect_uris=[AnyUrl("http://localhost:12345/callback")],
grant_types=["authorization_code"],
scope="openid profile",
)

# Create authorization params with resource parameter (which Azure v2.0 doesn't support)
params = AuthorizationParams(
redirect_uri=AnyUrl("http://localhost:12345/callback"),
redirect_uri_provided_explicitly=True,
state="test_state_123",
code_challenge="test_challenge",
code_challenge_method="S256",
scopes=["openid", "profile"],
resource="https://graph.microsoft.com", # This should be filtered out
)

# Mock the parent class's authorize method
with patch.object(
OAuthProxy,
"authorize",
new_callable=AsyncMock,
return_value="https://login.microsoftonline.com/test-tenant/oauth2/v2.0/authorize?test=1",
) as mock_authorize:
# Call the Azure provider's authorize method
await provider.authorize(client, params)

# Verify the parent's authorize was called
mock_authorize.assert_called_once()

# Get the params that were passed to the parent's authorize
call_args = mock_authorize.call_args
called_client = call_args[0][0]
called_params = call_args[0][1]

# Verify the resource parameter was filtered out
assert (
not hasattr(called_params, "resource") or called_params.resource is None
), "Resource parameter should have been filtered out"

# Verify other parameters were preserved
assert called_params.redirect_uri == params.redirect_uri
assert (
called_params.redirect_uri_provided_explicitly
== params.redirect_uri_provided_explicitly
)
assert called_params.state == params.state
assert called_params.code_challenge == params.code_challenge
assert called_params.scopes == params.scopes

# Verify the client was passed through unchanged
assert called_client == client

async def test_authorize_without_resource_parameter(self):
"""Test that authorize works normally when no resource parameter is present."""

provider = AzureProvider(
client_id="test_client",
client_secret="test_secret",
tenant_id="test-tenant",
base_url="https://myserver.com",
)

# Create a mock client
client = OAuthClientInformationFull(
client_id="test_client_123",
client_secret="client_secret_456",
redirect_uris=[AnyUrl("http://localhost:12345/callback")],
grant_types=["authorization_code"],
scope="openid profile",
)

# Create authorization params WITHOUT resource parameter
params = AuthorizationParams(
redirect_uri=AnyUrl("http://localhost:12345/callback"),
redirect_uri_provided_explicitly=True,
state="test_state_123",
code_challenge="test_challenge",
code_challenge_method="S256",
scopes=["openid", "profile"],
# No resource parameter
)

# Mock the parent class's authorize method
with patch.object(
OAuthProxy,
"authorize",
new_callable=AsyncMock,
return_value="https://login.microsoftonline.com/test-tenant/oauth2/v2.0/authorize?test=1",
) as mock_authorize:
# Call should work without issues
result = await provider.authorize(client, params)

# Verify the parent's authorize was called
mock_authorize.assert_called_once()
assert result is not None
Loading