diff --git a/docs/integrations/azure.mdx b/docs/integrations/azure.mdx
index 2ceefe482..2d2595a86 100644
--- a/docs/integrations/azure.mdx
+++ b/docs/integrations/azure.mdx
@@ -10,7 +10,7 @@ import { VersionBadge } from "/snippets/version-badge.mdx"
-This guide shows you how to secure your FastMCP server using **Azure OAuth** (Microsoft Entra ID). Since Azure doesn't support Dynamic Client Registration, this integration uses the [**OAuth Proxy**](/servers/auth/oauth-proxy) pattern to bridge Azure's traditional OAuth with MCP's authentication requirements.
+This guide shows you how to secure your FastMCP server using **Azure OAuth** (Microsoft Entra ID). Since Azure doesn't support Dynamic Client Registration, this integration uses the [**OAuth Proxy**](/servers/auth/oauth-proxy) pattern to bridge Azure's traditional OAuth with MCP's authentication requirements. FastMCP validates Azure JWTs against your application's client_id.
## Configuration
@@ -49,8 +49,39 @@ Create an App registration in Azure Portal to get the credentials needed for aut
If you want to use a custom callback path (e.g., `/auth/azure/callback`), make sure to set the same path in both your Azure App registration and the `redirect_path` parameter when configuring the AzureProvider.
+
+ - **Expose an API**: Configure your Application ID URI and define scopes
+ - Go to **Expose an API** in the App registration sidebar.
+ - Click **Set** next to "Application ID URI" and choose one of:
+ - Keep the default `api://{client_id}`
+ - Set a custom value, following the supported formats (see [Identifier URI restrictions](https://learn.microsoft.com/en-us/entra/identity-platform/identifier-uri-restrictions))
+ - Click **Add a scope** and create a scope your app will require, for example:
+ - Scope name: `read` (or `write`, etc.)
+ - Admin consent display name/description: as appropriate for your org
+ - Who can consent: as needed (Admins only or Admins and users)
+
+ - **Configure Access Token Version**: Ensure your app uses access token v2
+ - Go to **Manifest** in the App registration sidebar.
+ - Find the `requestedAccessTokenVersion` property and set it to `2`:
+ ```json
+ "api": {
+ "requestedAccessTokenVersion": 2
+ }
+ ```
+ - Click **Save** at the top of the manifest editor.
+
+
+ Access token v2 is required for FastMCP's Azure integration to work correctly. If this is not set, you may encounter authentication errors.
+
+
+
+ In FastMCP's `AzureProvider`, set `identifier_uri` to your Application ID URI (optional; defaults to `api://{client_id}`) and set `required_scopes` to the unprefixed scope names (e.g., `read`, `write`). During authorization, FastMCP automatically prefixes scopes with your `identifier_uri`.
+
+
+
+
After registration, navigate to **Certificates & secrets** in your app's settings.
@@ -91,7 +122,11 @@ 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=["User.Read", "email", "openid", "profile"], # Microsoft Graph permissions
+ required_scopes=["your-scope"], # Name of scope created when configuring your App
+ # identifier_uri defaults to api://{client_id}
+ # identifier_uri="api://your-api-id",
+ # Optional: request additional upstream scopes in the authorize request
+ # additional_authorize_scopes=["User.Read", "offline_access", "openid", "email"],
# redirect_path="/auth/callback" # Default value, customize if needed
)
@@ -215,12 +250,16 @@ Public URL of your FastMCP server for OAuth callbacks
Redirect path configured in your Azure App registration
-
-Comma-, space-, or JSON-separated list of required Microsoft Graph scopes
+
+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.
+
+
+
+Comma-, space-, or JSON-separated list of additional scopes to include in the authorization request without prefixing. Use this to request upstream scopes such as Microsoft Graph permissions. These are not used for token validation.
-
-HTTP request timeout for Microsoft Graph API calls
+
+Application ID URI used to prefix scopes during authorization.
@@ -234,7 +273,11 @@ FASTMCP_SERVER_AUTH_AZURE_CLIENT_ID=835f09b6-0f0f-40cc-85cb-f32c5829a149
FASTMCP_SERVER_AUTH_AZURE_CLIENT_SECRET=your-client-secret-here
FASTMCP_SERVER_AUTH_AZURE_TENANT_ID=08541b6e-646d-43de-a0eb-834e6713d6d5
FASTMCP_SERVER_AUTH_AZURE_BASE_URL=https://your-server.com
-FASTMCP_SERVER_AUTH_AZURE_REQUIRED_SCOPES=User.Read,email,profile
+FASTMCP_SERVER_AUTH_AZURE_REQUIRED_SCOPES=read,write
+# Optional custom API configuration
+# FASTMCP_SERVER_AUTH_AZURE_IDENTIFIER_URI=api://your-api-id
+# Request additional upstream scopes (optional)
+# FASTMCP_SERVER_AUTH_AZURE_ADDITIONAL_AUTHORIZE_SCOPES=User.Read,Mail.Read
```
With environment variables set, your server code simplifies to:
diff --git a/src/fastmcp/server/auth/providers/azure.py b/src/fastmcp/server/auth/providers/azure.py
index cd6a2ded9..2f1cee8fa 100644
--- a/src/fastmcp/server/auth/providers/azure.py
+++ b/src/fastmcp/server/auth/providers/azure.py
@@ -6,12 +6,16 @@
from __future__ import annotations
-import httpx
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING: # pragma: no cover
+ 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
-from fastmcp.server.auth import AccessToken, TokenVerifier
from fastmcp.server.auth.oauth_proxy import OAuthProxy
+from fastmcp.server.auth.providers.jwt import JWTVerifier
from fastmcp.utilities.auth import parse_scopes
from fastmcp.utilities.logging import get_logger
from fastmcp.utilities.storage import KVStorage
@@ -32,87 +36,22 @@ class AzureProviderSettings(BaseSettings):
client_id: str | None = None
client_secret: SecretStr | None = None
tenant_id: str | None = None
+ identifier_uri: str | None = None
base_url: str | None = None
redirect_path: str | None = None
required_scopes: list[str] | None = None
- timeout_seconds: int | None = None
+ additional_authorize_scopes: list[str] | None = None
allowed_client_redirect_uris: list[str] | None = None
@field_validator("required_scopes", mode="before")
@classmethod
- def _parse_scopes(cls, v):
+ def _parse_scopes(cls, v: object) -> list[str] | None:
return parse_scopes(v)
-
-class AzureTokenVerifier(TokenVerifier):
- """Token verifier for Azure OAuth tokens.
-
- Azure tokens are JWTs, but we verify them by calling the Microsoft Graph API
- to get user information and validate the token.
- """
-
- def __init__(
- self,
- *,
- required_scopes: list[str] | None = None,
- timeout_seconds: int = 10,
- ):
- """Initialize the Azure token verifier.
-
- Args:
- required_scopes: Required OAuth scopes
- timeout_seconds: HTTP request timeout
- """
- super().__init__(required_scopes=required_scopes)
- self.timeout_seconds = timeout_seconds
-
- async def verify_token(self, token: str) -> AccessToken | None:
- """Verify Azure OAuth token by calling Microsoft Graph API."""
- try:
- async with httpx.AsyncClient(timeout=self.timeout_seconds) as client:
- # Use Microsoft Graph API to validate token and get user info
- response = await client.get(
- "https://graph.microsoft.com/v1.0/me",
- headers={
- "Authorization": f"Bearer {token}",
- "User-Agent": "FastMCP-Azure-OAuth",
- },
- )
-
- if response.status_code != 200:
- logger.debug(
- "Azure token verification failed: %d - %s",
- response.status_code,
- response.text[:200],
- )
- return None
-
- user_data = response.json()
-
- # Create AccessToken with Azure user info
- return AccessToken(
- token=token,
- client_id=str(user_data.get("id", "unknown")),
- scopes=self.required_scopes or [],
- expires_at=None,
- claims={
- "sub": user_data.get("id"),
- "email": user_data.get("mail")
- or user_data.get("userPrincipalName"),
- "name": user_data.get("displayName"),
- "given_name": user_data.get("givenName"),
- "family_name": user_data.get("surname"),
- "job_title": user_data.get("jobTitle"),
- "office_location": user_data.get("officeLocation"),
- },
- )
-
- except httpx.RequestError as e:
- logger.debug("Failed to verify Azure token: %s", e)
- return None
- except Exception as e:
- logger.debug("Azure token verification error: %s", e)
- return None
+ @field_validator("additional_authorize_scopes", mode="before")
+ @classmethod
+ def _parse_additional_authorize_scopes(cls, v: object) -> list[str] | None:
+ return parse_scopes(v)
class AzureProvider(OAuthProxy):
@@ -123,16 +62,17 @@ class AzureProvider(OAuthProxy):
Microsoft accounts depending on the tenant configuration.
Features:
- - Transparent OAuth proxy to Azure/Microsoft identity platform
- - Automatic token validation via Microsoft Graph API
- - User information extraction
- - Support for different tenant configurations (common, organizations, consumers)
-
- Setup Requirements:
- 1. Register an application in Azure Portal (portal.azure.com)
- 2. Configure redirect URI as: http://localhost:8000/auth/callback
- 3. Note your Application (client) ID and create a client secret
- 4. Optionally note your Directory (tenant) ID for single-tenant apps
+ - OAuth proxy to Azure/Microsoft identity platform
+ - JWT validation using tenant issuer and JWKS
+ - Supports tenant configurations: specific tenant ID, "organizations", or "consumers"
+
+ Setup:
+ 1. Create an App registration in Azure Portal
+ 2. Configure Web platform redirect URI: http://localhost:8000/auth/callback (or your custom path)
+ 3. Add an Application ID URI. Either use the default (api://{client_id}) or set a custom one.
+ 4. Add a custom scope.
+ 5. Create a client secret.
+ 6. Get Application (client) ID, Directory (tenant) ID, and client secret
Example:
```python
@@ -142,8 +82,10 @@ class AzureProvider(OAuthProxy):
auth = AzureProvider(
client_id="your-client-id",
client_secret="your-client-secret",
- tenant_id="your-tenant-id", # Required: your Azure tenant ID from Azure Portal
- base_url="http://localhost:8000"
+ tenant_id="your-tenant-id",
+ required_scopes=["your-scope"],
+ base_url="http://localhost:8000",
+ # identifier_uri defaults to api://{client_id}
)
mcp = FastMCP("My App", auth=auth)
@@ -156,23 +98,30 @@ def __init__(
client_id: str | NotSetT = NotSet,
client_secret: str | NotSetT = NotSet,
tenant_id: str | NotSetT = NotSet,
+ identifier_uri: str | None | NotSetT = NotSet,
base_url: str | NotSetT = NotSet,
redirect_path: str | NotSetT = NotSet,
required_scopes: list[str] | None | NotSetT = NotSet,
- timeout_seconds: int | NotSetT = NotSet,
+ additional_authorize_scopes: list[str] | None | NotSetT = NotSet,
allowed_client_redirect_uris: list[str] | NotSetT = NotSet,
client_storage: KVStorage | None = None,
- ):
+ ) -> None:
"""Initialize Azure OAuth provider.
Args:
client_id: Azure application (client) ID
client_secret: Azure client secret
tenant_id: Azure tenant ID (your specific tenant ID, "organizations", or "consumers")
+ identifier_uri: Optional Application ID URI for your API. (defaults to api://{client_id})
+ Used only to prefix scopes in authorization requests. Tokens are always validated
+ against your app's client ID.
base_url: Public URL of your FastMCP server (for OAuth callbacks)
redirect_path: Redirect path configured in Azure (defaults to "/auth/callback")
- required_scopes: Required scopes (defaults to ["User.Read", "email", "openid", "profile"])
- timeout_seconds: HTTP request timeout for Azure API calls
+ required_scopes: Required scopes. These are validated on tokens and used as defaults
+ when the client does not request specific scopes.
+ additional_authorize_scopes: Additional scopes to include in the authorization request
+ without prefixing. Use this to request upstream scopes such as Microsoft Graph
+ permissions. These are not used for token validation.
allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients.
If None (default), all URIs are allowed. If empty list, no URIs are allowed.
client_storage: Storage implementation for OAuth client registrations.
@@ -185,10 +134,11 @@ def __init__(
"client_id": client_id,
"client_secret": client_secret,
"tenant_id": tenant_id,
+ "identifier_uri": identifier_uri,
"base_url": base_url,
"redirect_path": redirect_path,
"required_scopes": required_scopes,
- "timeout_seconds": timeout_seconds,
+ "additional_authorize_scopes": additional_authorize_scopes,
"allowed_client_redirect_uris": allowed_client_redirect_uris,
}.items()
if v is not NotSet
@@ -197,45 +147,48 @@ def __init__(
# Validate required settings
if not settings.client_id:
- raise ValueError(
- "client_id is required - set via parameter or FASTMCP_SERVER_AUTH_AZURE_CLIENT_ID"
- )
+ msg = "client_id is required - set via parameter or FASTMCP_SERVER_AUTH_AZURE_CLIENT_ID"
+ raise ValueError(msg)
if not settings.client_secret:
- raise ValueError(
- "client_secret is required - set via parameter or FASTMCP_SERVER_AUTH_AZURE_CLIENT_SECRET"
- )
+ msg = "client_secret is required - set via parameter or FASTMCP_SERVER_AUTH_AZURE_CLIENT_SECRET"
+ raise ValueError(msg)
# Validate tenant_id is provided
if not settings.tenant_id:
- raise ValueError(
- "tenant_id is required - set via parameter or FASTMCP_SERVER_AUTH_AZURE_TENANT_ID. "
- "Use your Azure tenant ID (found in Azure Portal), 'organizations', or 'consumers'"
+ msg = (
+ "tenant_id is required - set via parameter or "
+ "FASTMCP_SERVER_AUTH_AZURE_TENANT_ID. Use your Azure tenant ID "
+ "(found in Azure Portal), 'organizations', or 'consumers'"
)
+ raise ValueError(msg)
+
+ if not settings.required_scopes:
+ raise ValueError("required_scopes is required")
# 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
- timeout_seconds_final = settings.timeout_seconds or 10
- # Default scopes for Azure - User.Read gives us access to user info via Graph API
- scopes_final = settings.required_scopes or [
- "User.Read",
- "email",
- "openid",
- "profile",
- ]
- allowed_client_redirect_uris_final = settings.allowed_client_redirect_uris
+ # 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"
+ )
+
+ token_verifier = JWTVerifier(
+ jwks_uri=jwks_uri,
+ issuer=issuer,
+ audience=settings.client_id,
+ algorithm="RS256",
+ required_scopes=settings.required_scopes,
+ )
# Extract secret string from SecretStr
client_secret_str = (
settings.client_secret.get_secret_value() if settings.client_secret else ""
)
- # Create Azure token verifier
- token_verifier = AzureTokenVerifier(
- required_scopes=scopes_final,
- timeout_seconds=timeout_seconds_final,
- )
-
# Build Azure OAuth endpoints with tenant
authorization_endpoint = (
f"https://login.microsoftonline.com/{tenant_id_final}/oauth2/v2.0/authorize"
@@ -254,12 +207,65 @@ def __init__(
base_url=settings.base_url,
redirect_path=settings.redirect_path,
issuer_url=settings.base_url,
- allowed_client_redirect_uris=allowed_client_redirect_uris_final,
+ allowed_client_redirect_uris=settings.allowed_client_redirect_uris,
client_storage=client_storage,
)
logger.info(
- "Initialized Azure OAuth provider for client %s with tenant %s",
+ "Initialized Azure OAuth provider for client %s with tenant %s%s",
settings.client_id,
tenant_id_final,
+ f" and identifier_uri {self.identifier_uri}" if self.identifier_uri else "",
)
+
+ 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
+ params_to_use = params
+ if hasattr(params, "resource"):
+ original_resource = getattr(params, "resource", None)
+ if original_resource is not None:
+ params_to_use = params.model_copy(update={"resource": None})
+ if original_resource:
+ logger.debug(
+ "Filtering out 'resource' parameter '%s' for Azure AD v2.0 (use scopes instead)",
+ original_resource,
+ )
+ original_scopes = params_to_use.scopes or self.required_scopes
+ prefixed_scopes = (
+ self._add_prefix_to_scopes(original_scopes)
+ if self.identifier_uri
+ else original_scopes
+ )
+
+ final_scopes = list(prefixed_scopes)
+ if self.additional_authorize_scopes:
+ final_scopes.extend(self.additional_authorize_scopes)
+
+ modified_params = params_to_use.model_copy(update={"scopes": final_scopes})
+
+ auth_url = await super().authorize(client, modified_params)
+ separator = "&" if "?" in auth_url else "?"
+ return f"{auth_url}{separator}prompt=select_account"
+
+ def _add_prefix_to_scopes(self, scopes: list[str]) -> list[str]:
+ """Add Application ID URI prefix for authorization request."""
+ return [f"{self.identifier_uri}/{scope}" for scope in scopes]
diff --git a/tests/server/auth/providers/test_azure.py b/tests/server/auth/providers/test_azure.py
index 2c08df8d2..ec360403a 100644
--- a/tests/server/auth/providers/test_azure.py
+++ b/tests/server/auth/providers/test_azure.py
@@ -2,11 +2,15 @@
import os
from unittest.mock import patch
-from urllib.parse import urlparse
+from urllib.parse import parse_qs, urlparse
import pytest
+from mcp.server.auth.provider import AuthorizationParams
+from mcp.shared.auth import OAuthClientInformationFull
+from pydantic import AnyUrl
from fastmcp.server.auth.providers.azure import AzureProvider
+from fastmcp.server.auth.providers.jwt import JWTVerifier
class TestAzureProvider:
@@ -95,6 +99,7 @@ def test_init_defaults(self):
client_id="test_client",
client_secret="test_secret",
tenant_id="test-tenant",
+ required_scopes=["User.Read"],
)
# Check defaults
@@ -109,6 +114,7 @@ def test_oauth_endpoints_configured_correctly(self):
client_secret="test_secret",
tenant_id="my-tenant-id",
base_url="https://myserver.com",
+ required_scopes=["User.Read"],
)
# Check that endpoints use the correct Azure OAuth2 v2.0 endpoints with tenant
@@ -131,6 +137,7 @@ def test_special_tenant_values(self):
client_id="test_client",
client_secret="test_secret",
tenant_id="organizations",
+ required_scopes=["User.Read"],
)
parsed = urlparse(provider1._upstream_authorization_endpoint)
assert "/organizations/" in parsed.path
@@ -140,6 +147,7 @@ def test_special_tenant_values(self):
client_id="test_client",
client_secret="test_secret",
tenant_id="consumers",
+ required_scopes=["User.Read"],
)
parsed = urlparse(provider2._upstream_authorization_endpoint)
assert "/consumers/" in parsed.path
@@ -162,3 +170,107 @@ def test_azure_specific_scopes(self):
# Provider should initialize successfully with these scopes
assert provider is not None
+
+ def test_init_does_not_require_api_client_id_anymore(self):
+ """API client ID is no longer required; audience is client_id."""
+ provider = AzureProvider(
+ client_id="test_client",
+ client_secret="test_secret",
+ tenant_id="test-tenant",
+ required_scopes=["User.Read"],
+ )
+ assert provider is not None
+
+ def test_init_with_custom_audience_uses_jwt_verifier(self):
+ """When audience is provided, JWTVerifier is configured with JWKS and issuer."""
+ provider = AzureProvider(
+ client_id="test_client",
+ client_secret="test_secret",
+ tenant_id="my-tenant",
+ identifier_uri="api://my-api",
+ required_scopes=[".default"],
+ )
+
+ assert provider._token_validator is not None
+ assert isinstance(provider._token_validator, JWTVerifier)
+ verifier = provider._token_validator
+ assert verifier.jwks_uri is not None
+ assert verifier.jwks_uri.startswith(
+ "https://login.microsoftonline.com/my-tenant/discovery/v2.0/keys"
+ )
+ assert verifier.issuer == "https://login.microsoftonline.com/my-tenant/v2.0"
+ assert verifier.audience == "test_client"
+
+ @pytest.mark.asyncio
+ async def test_authorize_filters_resource_and_prefixes_scopes_with_audience(self):
+ """authorize() should drop resource and prefix non-openid scopes with audience."""
+ provider = AzureProvider(
+ client_id="test_client",
+ client_secret="test_secret",
+ tenant_id="common",
+ identifier_uri="api://my-api",
+ required_scopes=["read", "write"],
+ base_url="https://srv.example",
+ )
+
+ client = OAuthClientInformationFull(
+ client_id="dummy",
+ client_secret="secret",
+ redirect_uris=[AnyUrl("http://localhost:12345/callback")],
+ )
+
+ params = AuthorizationParams(
+ redirect_uri=AnyUrl("http://localhost:12345/callback"),
+ redirect_uri_provided_explicitly=True,
+ scopes=["read", "profile"],
+ state="abc",
+ code_challenge="xyz",
+ resource="https://should.be.ignored",
+ )
+
+ url = await provider.authorize(client, params)
+
+ parsed = urlparse(url)
+ qs = parse_qs(parsed.query)
+ assert "resource" not in qs
+ scope_value = qs.get("scope", [""])[0]
+ scope_parts = scope_value.split(" ") if scope_value else []
+ assert "api://my-api/read" in scope_parts
+ assert "api://my-api/profile" in scope_parts
+
+ @pytest.mark.asyncio
+ async def test_authorize_appends_unprefixed_additional_scopes(self):
+ """authorize() should append additional_authorize_scopes without prefixing them."""
+ provider = AzureProvider(
+ client_id="test_client",
+ client_secret="test_secret",
+ tenant_id="common",
+ identifier_uri="api://my-api",
+ required_scopes=["read"],
+ base_url="https://srv.example",
+ additional_authorize_scopes=["Mail.Read", "User.Read"],
+ )
+
+ client = OAuthClientInformationFull(
+ client_id="dummy",
+ client_secret="secret",
+ redirect_uris=[AnyUrl("http://localhost:12345/callback")],
+ )
+
+ params = AuthorizationParams(
+ redirect_uri=AnyUrl("http://localhost:12345/callback"),
+ redirect_uri_provided_explicitly=True,
+ scopes=["read"],
+ state="abc",
+ code_challenge="xyz",
+ )
+
+ url = await provider.authorize(client, params)
+
+ parsed = urlparse(url)
+ qs = parse_qs(parsed.query)
+ scope_value = qs.get("scope", [""])[0]
+ scope_parts = scope_value.split(" ") if scope_value else []
+ assert "api://my-api/read" in scope_parts
+ assert "Mail.Read" in scope_parts
+ assert "User.Read" in scope_parts