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 docs/integrations/azure.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ auth_provider = AzureProvider(
client_secret="your-client-secret", # Your Azure App Client Secret
tenant_id="08541b6e-646d-43de-a0eb-834e6713d6d5", # Your Azure Tenant ID (REQUIRED)
base_url="http://localhost:8000", # Must match your App registration
required_scopes=["your-scope"], # Name of scope created when configuring your App
required_scopes=["your-scope"], # At least one scope REQUIRED - name of scope from your App
# identifier_uri defaults to api://{client_id}
# identifier_uri="api://your-api-id",
# Optional: request additional upstream scopes in the authorize request
Expand Down Expand Up @@ -159,6 +159,10 @@ async def get_user_info() -> dict:
Using your specific tenant ID is recommended for better security and control.
</Note>

<Note>
**Important**: The `required_scopes` parameter is **REQUIRED** and must include at least one scope. Azure's OAuth API requires the `scope` parameter in all authorization requests - you cannot authenticate without specifying at least one scope. Use the unprefixed scope names from your Azure App registration (e.g., `["read", "write"]`). These scopes must be created under **Expose an API** in your App registration.
</Note>

## Testing

### Running the Server
Expand Down Expand Up @@ -296,8 +300,12 @@ Issuer URL for OAuth metadata (defaults to `BASE_URL`). Set to root-level URL wh
Redirect path configured in your Azure App registration
</ParamField>

<ParamField path="FASTMCP_SERVER_AUTH_AZURE_REQUIRED_SCOPES" default="">
Comma-, space-, or JSON-separated list of required scopes for your API. These are validated on tokens and used as defaults if the client does not request specific scopes.
<ParamField path="FASTMCP_SERVER_AUTH_AZURE_REQUIRED_SCOPES" required>
Comma-, space-, or JSON-separated list of required scopes for your API (at least one scope required). These are validated on tokens and used as defaults if the client does not request specific scopes. Use unprefixed scope names from your Azure App registration (e.g., `read,write`).

<Note>
Azure's OAuth API requires the `scope` parameter - you must provide at least one scope.
</Note>
</ParamField>

<ParamField path="FASTMCP_SERVER_AUTH_AZURE_ADDITIONAL_AUTHORIZE_SCOPES" default="">
Expand Down
11 changes: 8 additions & 3 deletions examples/auth/azure_oauth/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
- AZURE_CLIENT_SECRET: Your Azure client secret
- AZURE_TENANT_ID: Tenant ID
Options: "organizations" (work/school), "consumers" (personal), or specific tenant ID
- AZURE_REQUIRED_SCOPES: At least one scope required (e.g., "read" or "read,write")
These must match scope names created under "Expose an API" in your Azure App registration

To run:
python server.py
Expand All @@ -18,11 +20,14 @@
from fastmcp.server.auth.providers.azure import AzureProvider

auth = AzureProvider(
client_id=os.getenv("AZURE_CLIENT_ID") or "",
client_secret=os.getenv("AZURE_CLIENT_SECRET") or "",
tenant_id=os.getenv("AZURE_TENANT_ID")
client_id=os.getenv("FASTMCP_SERVER_AUTH_AZURE_CLIENT_ID") or "",
client_secret=os.getenv("FASTMCP_SERVER_AUTH_AZURE_CLIENT_SECRET") or "",
tenant_id=os.getenv("FASTMCP_SERVER_AUTH_AZURE_TENANT_ID")
or "", # Required for single-tenant apps - get from Azure Portal
base_url="http://localhost:8000",
required_scopes=["read"],
# required_scopes is automatically loaded from FASTMCP_SERVER_AUTH_AZURE_REQUIRED_SCOPES
# At least one scope is required - use unprefixed scope names from your Azure App (e.g., ["read", "write"])
# redirect_path="/auth/callback", # Default path - change if using a different callback URL
)

Expand Down
67 changes: 45 additions & 22 deletions src/fastmcp/server/auth/providers/azure.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from __future__ import annotations

from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any

from key_value.aio.protocols import AsyncKeyValue
from pydantic import SecretStr, field_validator
Expand Down Expand Up @@ -202,32 +202,34 @@ def __init__(
)
raise ValueError(msg)

# Validate required_scopes has at least one scope
if not settings.required_scopes:
raise ValueError("required_scopes is required")
msg = (
"required_scopes must include at least one scope - set via parameter or "
"FASTMCP_SERVER_AUTH_AZURE_REQUIRED_SCOPES. Azure's OAuth API requires "
"the 'scope' parameter in authorization requests. Use the unprefixed scope "
"names from your Azure App registration (e.g., ['read', 'write'])"
)
raise ValueError(msg)

# Apply defaults
self.identifier_uri = settings.identifier_uri or f"api://{settings.client_id}"
self.additional_authorize_scopes = settings.additional_authorize_scopes or []
tenant_id_final = settings.tenant_id

# Prefix required scopes with identifier_uri for Azure
# Azure returns scopes as full URIs (e.g., "api://xxx/read") in tokens
prefixed_required_scopes = [
f"{self.identifier_uri}/{scope}" for scope in settings.required_scopes
]

# Always validate tokens against the app's API client ID using JWT
issuer = f"https://login.microsoftonline.com/{tenant_id_final}/v2.0"
jwks_uri = (
f"https://login.microsoftonline.com/{tenant_id_final}/discovery/v2.0/keys"
)

# Azure returns unprefixed scopes in JWT tokens, so validate against unprefixed scopes
token_verifier = JWTVerifier(
jwks_uri=jwks_uri,
issuer=issuer,
audience=settings.client_id,
algorithm="RS256",
required_scopes=prefixed_required_scopes,
required_scopes=settings.required_scopes, # Unprefixed scopes for validation
)

# Extract secret string from SecretStr
Expand Down Expand Up @@ -298,19 +300,40 @@ async def authorize(
"Filtering out 'resource' parameter '%s' for Azure AD v2.0 (use scopes instead)",
original_resource,
)
# Scopes are already prefixed:
# - self.required_scopes was prefixed during __init__
# - Client scopes come from PRM which advertises prefixed scopes
scopes = params_to_use.scopes or self.required_scopes

final_scopes = list(scopes)
# Add Microsoft Graph scopes separately - these use shorthand format (e.g., "User.Read")
# and should not be prefixed with identifier_uri. Azure returns them as-is in tokens.
# Don't modify the scopes in params - they stay unprefixed for MCP clients
# We'll prefix them when building the Azure authorization URL (in _build_upstream_authorize_url)
auth_url = await super().authorize(client, params_to_use)
separator = "&" if "?" in auth_url else "?"
return f"{auth_url}{separator}prompt=select_account"

def _build_upstream_authorize_url(
self, txn_id: str, transaction: dict[str, Any]
) -> str:
"""Build Azure authorization URL with prefixed scopes.

Overrides parent to prefix scopes with identifier_uri before sending to Azure,
while keeping unprefixed scopes in the transaction for MCP clients.
"""
# Get unprefixed scopes from transaction
unprefixed_scopes = transaction.get("scopes") or self.required_scopes or []

# Prefix scopes for Azure authorization request
prefixed_scopes = []
for scope in unprefixed_scopes:
if "://" in scope or "/" in scope:
# Already a full URI or path (e.g., "api://xxx/read" or "User.Read")
prefixed_scopes.append(scope)
else:
# Unprefixed scope name - prefix it with identifier_uri
prefixed_scopes.append(f"{self.identifier_uri}/{scope}")

# Add Microsoft Graph scopes (not validated, not prefixed)
if self.additional_authorize_scopes:
final_scopes.extend(self.additional_authorize_scopes)
prefixed_scopes.extend(self.additional_authorize_scopes)

modified_params = params_to_use.model_copy(update={"scopes": final_scopes})
# Temporarily modify transaction dict for parent's URL building
modified_transaction = transaction.copy()
modified_transaction["scopes"] = prefixed_scopes

auth_url = await super().authorize(client, modified_params)
separator = "&" if "?" in auth_url else "?"
return f"{auth_url}{separator}prompt=select_account"
# Let parent build the URL with prefixed scopes
return super()._build_upstream_authorize_url(txn_id, modified_transaction)
141 changes: 103 additions & 38 deletions tests/server/auth/providers/test_azure.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
from pydantic import AnyUrl

from fastmcp.server.auth.providers.azure import AzureProvider
from fastmcp.server.auth.providers.jwt import JWTVerifier


class TestAzureProvider:
Expand Down Expand Up @@ -61,10 +60,11 @@ def test_init_with_env_vars(self, scopes_env):
assert provider._upstream_client_id == "env-client-id"
assert provider._upstream_client_secret.get_secret_value() == "env-secret"
assert str(provider.base_url) == "https://envserver.com/"
# Scopes should be prefixed with identifier_uri in token validator
# Scopes are stored unprefixed for token validation
# (Azure returns unprefixed scopes in JWT tokens)
assert provider._token_validator.required_scopes == [
"api://env-client-id/read",
"api://env-client-id/write",
"read",
"write",
]
# Check tenant is in the endpoints
parsed_auth = urlparse(provider._upstream_authorization_endpoint)
Expand All @@ -74,27 +74,63 @@ def test_init_with_env_vars(self, scopes_env):

def test_init_missing_client_id_raises_error(self):
"""Test that missing client_id raises ValueError."""
with pytest.raises(ValueError, match="client_id is required"):
AzureProvider(
client_secret="test_secret",
tenant_id="test-tenant",
)
# Clear environment variables to ensure we're testing the parameter validation
with patch.dict(os.environ, {}, clear=True):
with pytest.raises(ValueError, match="client_id is required"):
AzureProvider(
client_secret="test_secret",
tenant_id="test-tenant",
required_scopes=["read"],
)

def test_init_missing_client_secret_raises_error(self):
"""Test that missing client_secret raises ValueError."""
with pytest.raises(ValueError, match="client_secret is required"):
AzureProvider(
client_id="test_client",
tenant_id="test-tenant",
)
# Clear environment variables to ensure we're testing the parameter validation
with patch.dict(os.environ, {}, clear=True):
with pytest.raises(ValueError, match="client_secret is required"):
AzureProvider(
client_id="test_client",
tenant_id="test-tenant",
required_scopes=["read"],
)

def test_init_missing_tenant_id_raises_error(self):
"""Test that missing tenant_id raises ValueError."""
with pytest.raises(ValueError, match="tenant_id is required"):
AzureProvider(
client_id="test_client",
client_secret="test_secret",
)
# Clear environment variables to ensure we're testing the parameter validation
with patch.dict(os.environ, {}, clear=True):
with pytest.raises(ValueError, match="tenant_id is required"):
AzureProvider(
client_id="test_client",
client_secret="test_secret",
required_scopes=["read"],
)

def test_init_missing_required_scopes_raises_error(self):
"""Test that missing required_scopes raises ValueError."""
# Clear environment variables to ensure we're testing the parameter validation
with patch.dict(os.environ, {}, clear=True):
with pytest.raises(
ValueError, match="required_scopes must include at least one scope"
):
AzureProvider(
client_id="test_client",
client_secret="test_secret",
tenant_id="test-tenant",
)

def test_init_empty_required_scopes_raises_error(self):
"""Test that empty required_scopes raises ValueError."""
# Clear environment variables to ensure we're testing the parameter validation
with patch.dict(os.environ, {}, clear=True):
with pytest.raises(
ValueError, match="required_scopes must include at least one scope"
):
AzureProvider(
client_id="test_client",
client_secret="test_secret",
tenant_id="test-tenant",
required_scopes=[],
)

def test_init_defaults(self):
"""Test that default values are applied correctly."""
Expand Down Expand Up @@ -176,11 +212,12 @@ def test_azure_specific_scopes(self):

# Provider should initialize successfully with these scopes
assert provider is not None
# Scopes should be prefixed in token validator
# Scopes are stored unprefixed for token validation
# (Azure returns unprefixed scopes in JWT tokens)
assert provider._token_validator.required_scopes == [
"api://test_client/read",
"api://test_client/write",
"api://test_client/admin",
"read",
"write",
"admin",
]

def test_init_does_not_require_api_client_id_anymore(self):
Expand All @@ -196,6 +233,8 @@ def test_init_does_not_require_api_client_id_anymore(self):

def test_init_with_custom_audience_uses_jwt_verifier(self):
"""When audience is provided, JWTVerifier is configured with JWKS and issuer."""
from fastmcp.server.auth.providers.jwt import JWTVerifier

provider = AzureProvider(
client_id="test_client",
client_secret="test_secret",
Expand All @@ -214,11 +253,12 @@ def test_init_with_custom_audience_uses_jwt_verifier(self):
)
assert verifier.issuer == "https://login.microsoftonline.com/my-tenant/v2.0"
assert verifier.audience == "test_client"
# Scopes should be prefixed with identifier_uri
assert verifier.required_scopes == ["api://my-api/.default"]
# Scopes are stored unprefixed for token validation
# (Azure returns unprefixed scopes like ".default" in JWT tokens)
assert verifier.required_scopes == [".default"]

async def test_authorize_filters_resource_and_accepts_prefixed_scopes(self):
"""authorize() should drop resource parameter and accept prefixed scopes from clients."""
async def test_authorize_filters_resource_and_stores_unprefixed_scopes(self):
"""authorize() should drop resource parameter and store unprefixed scopes for MCP clients."""
provider = AzureProvider(
client_id="test_client",
client_secret="test_secret",
Expand Down Expand Up @@ -247,9 +287,9 @@ async def test_authorize_filters_resource_and_accepts_prefixed_scopes(self):
redirect_uri=AnyUrl("http://localhost:12345/callback"),
redirect_uri_provided_explicitly=True,
scopes=[
"api://my-api/read",
"api://my-api/profile",
], # Client sends prefixed scopes from PRM
"read",
"profile",
], # Client sends unprefixed scopes (from PRM which advertises unprefixed)
state="abc",
code_challenge="xyz",
resource="https://should.be.ignored",
Expand All @@ -263,14 +303,27 @@ async def test_authorize_filters_resource_and_accepts_prefixed_scopes(self):
assert "txn_id" in qs, "Should redirect to consent page with transaction ID"
txn_id = qs["txn_id"][0]

# Verify transaction contains correct parameters (resource filtered, scopes prefixed)
# Verify transaction stores UNPREFIXED scopes for MCP clients
transaction = await provider._transaction_store.get(key=txn_id)
assert transaction is not None
assert "api://my-api/read" in transaction.scopes
assert "api://my-api/profile" in transaction.scopes
assert "read" in transaction.scopes
assert "profile" in transaction.scopes
# Azure provider filters resource parameter (not stored in transaction)
assert transaction.resource is None

# Verify the upstream Azure URL will have PREFIXED scopes
upstream_url = provider._build_upstream_authorize_url(
txn_id, transaction.model_dump()
)
assert (
"api%3A%2F%2Fmy-api%2Fread" in upstream_url
or "api://my-api/read" in upstream_url
)
assert (
"api%3A%2F%2Fmy-api%2Fprofile" in upstream_url
or "api://my-api/profile" in upstream_url
)

async def test_authorize_appends_additional_scopes(self):
"""authorize() should append additional_authorize_scopes to the authorization request."""
provider = AzureProvider(
Expand Down Expand Up @@ -301,7 +354,7 @@ async def test_authorize_appends_additional_scopes(self):
params = AuthorizationParams(
redirect_uri=AnyUrl("http://localhost:12345/callback"),
redirect_uri_provided_explicitly=True,
scopes=["api://my-api/read"], # Client sends prefixed scopes from PRM
scopes=["read"], # Client sends unprefixed scopes
state="abc",
code_challenge="xyz",
)
Expand All @@ -314,9 +367,21 @@ async def test_authorize_appends_additional_scopes(self):
assert "txn_id" in qs, "Should redirect to consent page with transaction ID"
txn_id = qs["txn_id"][0]

# Verify transaction contains correct scopes (prefixed + unprefixed additional)
# Verify transaction stores ONLY MCP scopes (unprefixed)
# additional_authorize_scopes are NOT stored in transaction
transaction = await provider._transaction_store.get(key=txn_id)
assert transaction is not None
assert "api://my-api/read" in transaction.scopes
assert "Mail.Read" in transaction.scopes
assert "User.Read" in transaction.scopes
assert "read" in transaction.scopes
assert "Mail.Read" not in transaction.scopes # Not in transaction
assert "User.Read" not in transaction.scopes # Not in transaction

# Verify upstream URL includes both MCP scopes (prefixed) AND additional Graph scopes
upstream_url = provider._build_upstream_authorize_url(
txn_id, transaction.model_dump()
)
assert (
"api%3A%2F%2Fmy-api%2Fread" in upstream_url
or "api://my-api/read" in upstream_url
)
assert "Mail.Read" in upstream_url
assert "User.Read" in upstream_url
Loading