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
30 changes: 30 additions & 0 deletions docs/integrations/azure.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,34 @@ Using your specific tenant ID is recommended for better security and control.
**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>

### Scope Handling

FastMCP automatically prefixes `required_scopes` with your `identifier_uri` (e.g., `api://your-client-id`) since these are your custom API scopes. Scopes in `additional_authorize_scopes` are sent as-is since they target external resources like Microsoft Graph.

**`required_scopes`** — Your custom API scopes, defined in Azure "Expose an API":

| You write | Sent to Azure | Validated on tokens |
|-----------|---------------|---------------------|
| `mcp-read` | `api://xxx/mcp-read` | ✓ |
| `my.scope` | `api://xxx/my.scope` | ✓ |
| `openid` | `openid` | ✗ (OIDC scope) |
| `api://xxx/read` | `api://xxx/read` | ✓ |

**`additional_authorize_scopes`** — External scopes (e.g., Microsoft Graph) for server-side use:

| You write | Sent to Azure | Validated on tokens |
|-----------|---------------|---------------------|
| `User.Read` | `User.Read` | ✗ |
| `Mail.Send` | `Mail.Send` | ✗ |

<Info>
**Why aren't `additional_authorize_scopes` validated?** Azure issues separate tokens per resource. The access token FastMCP receives is for *your API*—Graph scopes aren't in its `scp` claim. To call Graph APIs, your server uses the upstream Azure token in an on-behalf-of (OBO) flow.
</Info>

<Note>
OIDC scopes (`openid`, `profile`, `email`, `offline_access`) are never prefixed and excluded from validation because Azure doesn't include them in access token `scp` claims.
</Note>

## Testing

### Running the Server
Expand Down Expand Up @@ -304,6 +332,8 @@ Redirect path configured in your Azure App registration
<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`).

You can include standard OIDC scopes (`openid`, `profile`, `email`, `offline_access`) in `required_scopes`. FastMCP automatically handles them correctly: they're sent to Azure unprefixed and excluded from token validation (since Azure doesn't include OIDC scopes in access token `scp` claims).

<Note>
Azure's OAuth API requires the `scope` parameter - you must provide at least one scope.
</Note>
Expand Down
44 changes: 38 additions & 6 deletions src/fastmcp/server/auth/providers/azure.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@

logger = get_logger(__name__)

# Standard OIDC scopes that should never be prefixed with identifier_uri.
# Per Microsoft docs: https://learn.microsoft.com/en-us/entra/identity-platform/scopes-oidc
# "OIDC scopes are requested as simple string identifiers without resource prefixes"
OIDC_SCOPES = frozenset({"openid", "profile", "email", "offline_access"})


class AzureProviderSettings(BaseSettings):
"""Settings for Azure OAuth provider."""
Expand Down Expand Up @@ -240,13 +245,25 @@ def __init__(
f"https://{base_authority_final}/{tenant_id_final}/discovery/v2.0/keys"
)

# Azure returns unprefixed scopes in JWT tokens, so validate against unprefixed scopes
# Azure access tokens only include custom API scopes in the `scp` claim,
# NOT standard OIDC scopes (openid, profile, email, offline_access).
# Filter out OIDC scopes from validation - they'll still be sent to Azure
# during authorization (handled by _prefix_scopes_for_azure).
validation_scopes = None
if settings.required_scopes:
validation_scopes = [
s for s in settings.required_scopes if s not in OIDC_SCOPES
]
# If all scopes were OIDC scopes, use None (no scope validation)
if not validation_scopes:
validation_scopes = None

token_verifier = JWTVerifier(
jwks_uri=jwks_uri,
issuer=issuer,
audience=settings.client_id,
algorithm="RS256",
required_scopes=settings.required_scopes, # Unprefixed scopes for validation
required_scopes=validation_scopes, # Only validate non-OIDC scopes
)

# Extract secret string from SecretStr
Expand Down Expand Up @@ -277,6 +294,8 @@ def __init__(
client_storage=client_storage,
jwt_signing_key=settings.jwt_signing_key,
require_authorization_consent=require_authorization_consent,
# Advertise full scopes including OIDC (even though we only validate non-OIDC)
valid_scopes=settings.required_scopes,
)

authority_info = ""
Expand Down Expand Up @@ -328,11 +347,20 @@ async def authorize(
return f"{auth_url}{separator}prompt=select_account"

def _prefix_scopes_for_azure(self, scopes: list[str]) -> list[str]:
"""Prefix unprefixed scopes with identifier_uri for Azure.
"""Prefix unprefixed custom API scopes with identifier_uri for Azure.

This helper centralizes the scope prefixing logic used in both
authorization and token refresh flows.

Scopes that are NOT prefixed:
- Standard OIDC scopes (openid, profile, email, offline_access)
- Fully-qualified URIs (contain "://")
- Scopes with path component (contain "/")

Note: Microsoft Graph scopes (e.g., User.Read) should be passed via
`additional_authorize_scopes` or use fully-qualified format
(e.g., https://graph.microsoft.com/User.Read).

Args:
scopes: List of scopes, may be prefixed or unprefixed

Expand All @@ -341,11 +369,15 @@ def _prefix_scopes_for_azure(self, scopes: list[str]) -> list[str]:
"""
prefixed = []
for scope in scopes:
if "://" in scope or "/" in scope:
# Already fully-qualified (e.g., "api://xxx/read" or "User.Read")
if scope in OIDC_SCOPES:
# Standard OIDC scopes - never prefix
prefixed.append(scope)
elif "://" in scope or "/" in scope:
# Already fully-qualified (e.g., "api://xxx/read" or
# "https://graph.microsoft.com/User.Read")
prefixed.append(scope)
else:
# Unprefixed client scope - prefix with identifier_uri
# Unprefixed custom API scope - prefix with identifier_uri
prefixed.append(f"{self.identifier_uri}/{scope}")
return prefixed

Expand Down
180 changes: 175 additions & 5 deletions tests/server/auth/providers/test_azure.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from mcp.shared.auth import OAuthClientInformationFull
from pydantic import AnyUrl

from fastmcp.server.auth.providers.azure import AzureProvider
from fastmcp.server.auth.providers.azure import OIDC_SCOPES, AzureProvider


class TestAzureProvider:
Expand Down Expand Up @@ -288,7 +288,7 @@ async def test_authorize_filters_resource_and_stores_unprefixed_scopes(self):
redirect_uri_provided_explicitly=True,
scopes=[
"read",
"profile",
"write",
], # Client sends unprefixed scopes (from PRM which advertises unprefixed)
state="abc",
code_challenge="xyz",
Expand All @@ -307,7 +307,7 @@ async def test_authorize_filters_resource_and_stores_unprefixed_scopes(self):
transaction = await provider._transaction_store.get(key=txn_id)
assert transaction is not None
assert "read" in transaction.scopes
assert "profile" in transaction.scopes
assert "write" in transaction.scopes
# Azure provider filters resource parameter (not stored in transaction)
assert transaction.resource is None

Expand All @@ -320,8 +320,8 @@ async def test_authorize_filters_resource_and_stores_unprefixed_scopes(self):
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
"api%3A%2F%2Fmy-api%2Fwrite" in upstream_url
or "api://my-api/write" in upstream_url
)

async def test_authorize_appends_additional_scopes(self):
Expand Down Expand Up @@ -709,3 +709,173 @@ def test_prepare_scopes_for_upstream_refresh_deduplicates_prefixed_variants(self
# Should only have 2 items (read processed twice, but deduplicated)
assert len(result) == 2
assert result.count("api://my-api/read") == 1


class TestOIDCScopeHandling:
"""Tests for OIDC scope handling in Azure provider.

Azure access tokens do NOT include OIDC scopes (openid, profile, email,
offline_access) in the `scp` claim - they're only used during authorization.
These tests verify that:
1. OIDC scopes are never prefixed with identifier_uri
2. OIDC scopes are filtered from token validation
3. OIDC scopes are still advertised to clients via valid_scopes
"""

def test_oidc_scopes_constant(self):
"""Verify OIDC_SCOPES contains the standard OIDC scopes."""
assert OIDC_SCOPES == {"openid", "profile", "email", "offline_access"}

def test_prefix_scopes_does_not_prefix_oidc_scopes(self):
"""Test that _prefix_scopes_for_azure never prefixes OIDC scopes."""
provider = AzureProvider(
client_id="test_client",
client_secret="test_secret",
tenant_id="test-tenant",
identifier_uri="api://my-api",
required_scopes=["read"],
jwt_signing_key="test-secret",
)

# All OIDC scopes should pass through unchanged
result = provider._prefix_scopes_for_azure(
["openid", "profile", "email", "offline_access"]
)

assert result == ["openid", "profile", "email", "offline_access"]

def test_prefix_scopes_mixed_oidc_and_custom(self):
"""Test prefixing with a mix of OIDC and custom scopes."""
provider = AzureProvider(
client_id="test_client",
client_secret="test_secret",
tenant_id="test-tenant",
identifier_uri="api://my-api",
required_scopes=["read"],
jwt_signing_key="test-secret",
)

result = provider._prefix_scopes_for_azure(
["read", "openid", "write", "profile"]
)

# Custom scopes should be prefixed, OIDC scopes should not
assert "api://my-api/read" in result
assert "api://my-api/write" in result
assert "openid" in result
assert "profile" in result
# Verify OIDC scopes are NOT prefixed
assert "api://my-api/openid" not in result
assert "api://my-api/profile" not in result

def test_prefix_scopes_dot_notation_gets_prefixed(self):
"""Test that dot-notation scopes get prefixed (use additional_authorize_scopes for Graph)."""
provider = AzureProvider(
client_id="test_client",
client_secret="test_secret",
tenant_id="test-tenant",
identifier_uri="api://my-api",
required_scopes=["read"],
jwt_signing_key="test-secret",
)

# Dot-notation scopes ARE prefixed - use additional_authorize_scopes for Graph
# or fully-qualified format like https://graph.microsoft.com/User.Read
result = provider._prefix_scopes_for_azure(["my.scope", "admin.read"])

assert result == ["api://my-api/my.scope", "api://my-api/admin.read"]

def test_prefix_scopes_fully_qualified_graph_not_prefixed(self):
"""Test that fully-qualified Graph scopes are not prefixed."""
provider = AzureProvider(
client_id="test_client",
client_secret="test_secret",
tenant_id="test-tenant",
identifier_uri="api://my-api",
required_scopes=["read"],
jwt_signing_key="test-secret",
)

result = provider._prefix_scopes_for_azure(
[
"https://graph.microsoft.com/User.Read",
"https://graph.microsoft.com/Mail.Send",
]
)

# Fully-qualified URIs pass through unchanged
assert result == [
"https://graph.microsoft.com/User.Read",
"https://graph.microsoft.com/Mail.Send",
]

def test_required_scopes_with_oidc_filters_validation(self):
"""Test that OIDC scopes in required_scopes are filtered from token validation."""
provider = AzureProvider(
client_id="test_client",
client_secret="test_secret",
tenant_id="test-tenant",
identifier_uri="api://my-api",
required_scopes=["read", "openid", "profile"],
jwt_signing_key="test-secret",
)

# Token validator should only require non-OIDC scopes
assert provider._token_validator.required_scopes == ["read"]

def test_required_scopes_all_oidc_results_in_no_validation(self):
"""Test that if all required_scopes are OIDC, no scope validation occurs."""
provider = AzureProvider(
client_id="test_client",
client_secret="test_secret",
tenant_id="test-tenant",
identifier_uri="api://my-api",
required_scopes=["openid", "profile"],
jwt_signing_key="test-secret",
)

# Token validator should have empty required scopes (all were OIDC)
assert provider._token_validator.required_scopes == []

def test_valid_scopes_includes_oidc_scopes(self):
"""Test that valid_scopes advertises OIDC scopes to clients."""
provider = AzureProvider(
client_id="test_client",
client_secret="test_secret",
tenant_id="test-tenant",
identifier_uri="api://my-api",
required_scopes=["read", "openid", "profile"],
jwt_signing_key="test-secret",
)

# required_scopes (used for validation) excludes OIDC scopes
assert provider.required_scopes == ["read"]
# But valid_scopes (advertised to clients) includes all scopes
assert provider.client_registration_options.valid_scopes == [
"read",
"openid",
"profile",
]

def test_prepare_scopes_for_refresh_handles_oidc_scopes(self):
"""Test that token refresh correctly handles OIDC scopes."""
provider = AzureProvider(
client_id="test_client",
client_secret="test_secret",
tenant_id="test-tenant",
identifier_uri="api://my-api",
required_scopes=["read"],
jwt_signing_key="test-secret",
)

# Simulate stored scopes that include OIDC scopes
result = provider._prepare_scopes_for_upstream_refresh(
["read", "openid", "profile"]
)

# Custom scope should be prefixed, OIDC scopes should not
assert "api://my-api/read" in result
assert "openid" in result
assert "profile" in result
assert "api://my-api/openid" not in result
assert "api://my-api/profile" not in result
Loading