Fix Azure scope validation (#2269)

* Update docs for required scopes

* add scopes

* Fix Azure scope validation

Azure returns unprefixed scopes in JWT tokens but requires prefixed scopes in authorization requests. The previous implementation incorrectly validated tokens against prefixed scopes, causing "invalid_token" errors.

Simplified AzureProvider to use standard JWTVerifier with unprefixed scopes for validation. Scopes are only prefixed when building the Azure authorization URL via _build_upstream_authorize_url() override.

Closes #2263
This commit is contained in:
Jeremiah Lowin 2025-10-26 11:41:22 -04:00 committed by GitHub
commit ba47db9b8c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 167 additions and 66 deletions

View file

@ -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
@ -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
@ -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="">

View file

@ -7,6 +7,8 @@ Required environment variables:
- 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
@ -18,11 +20,14 @@ from fastmcp import FastMCP
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
)

View file

@ -6,7 +6,7 @@ using the OAuth Proxy pattern for non-DCR OAuth flows.
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
@ -202,32 +202,34 @@ class AzureProvider(OAuthProxy):
)
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
@ -298,19 +300,40 @@ class AzureProvider(OAuthProxy):
"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.
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)
# 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:
prefixed_scopes.extend(self.additional_authorize_scopes)
# Temporarily modify transaction dict for parent's URL building
modified_transaction = transaction.copy()
modified_transaction["scopes"] = prefixed_scopes
# Let parent build the URL with prefixed scopes
return super()._build_upstream_authorize_url(txn_id, modified_transaction)

View file

@ -10,7 +10,6 @@ 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:
@ -61,10 +60,11 @@ class TestAzureProvider:
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)
@ -74,27 +74,63 @@ class TestAzureProvider:
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."""
@ -176,11 +212,12 @@ class TestAzureProvider:
# 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):
@ -196,6 +233,8 @@ class TestAzureProvider:
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",
@ -214,11 +253,12 @@ class TestAzureProvider:
)
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",
@ -247,9 +287,9 @@ class TestAzureProvider:
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",
@ -263,14 +303,27 @@ class TestAzureProvider:
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(
@ -301,7 +354,7 @@ class TestAzureProvider:
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",
)
@ -314,9 +367,21 @@ class TestAzureProvider:
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