From 46c8836d87303b1a9f2ac80a432ab859962873c7 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 26 Oct 2025 10:55:12 -0400 Subject: [PATCH 1/3] Update docs for required scopes --- docs/integrations/azure.mdx | 14 ++++- examples/auth/azure_oauth/server.py | 10 +++- src/fastmcp/server/auth/providers/azure.py | 9 ++- tests/server/auth/providers/test_azure.py | 66 +++++++++++++++++----- 4 files changed, 77 insertions(+), 22 deletions(-) diff --git a/docs/integrations/azure.mdx b/docs/integrations/azure.mdx index 307bba209..f91af5a2f 100644 --- a/docs/integrations/azure.mdx +++ b/docs/integrations/azure.mdx @@ -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. + +**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. + + ## 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 - -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 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`). + + +Azure's OAuth API requires the `scope` parameter - you must provide at least one scope. + diff --git a/examples/auth/azure_oauth/server.py b/examples/auth/azure_oauth/server.py index 2d5062612..0dda5a70d 100644 --- a/examples/auth/azure_oauth/server.py +++ b/examples/auth/azure_oauth/server.py @@ -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 @@ -18,11 +20,13 @@ 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 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 ) diff --git a/src/fastmcp/server/auth/providers/azure.py b/src/fastmcp/server/auth/providers/azure.py index e7af79551..7894272f2 100644 --- a/src/fastmcp/server/auth/providers/azure.py +++ b/src/fastmcp/server/auth/providers/azure.py @@ -202,8 +202,15 @@ 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}" diff --git a/tests/server/auth/providers/test_azure.py b/tests/server/auth/providers/test_azure.py index 95d0eb754..3442ac7aa 100644 --- a/tests/server/auth/providers/test_azure.py +++ b/tests/server/auth/providers/test_azure.py @@ -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.""" From 1028c948e327fa2e56a5f65a267a98f963d4fb45 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 26 Oct 2025 11:08:26 -0400 Subject: [PATCH 2/3] add scopes --- examples/auth/azure_oauth/server.py | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/auth/azure_oauth/server.py b/examples/auth/azure_oauth/server.py index 0dda5a70d..d214389aa 100644 --- a/examples/auth/azure_oauth/server.py +++ b/examples/auth/azure_oauth/server.py @@ -25,6 +25,7 @@ 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 From 3a6e41d26e20ea04d0e2d2fb9328ccbd7b30ee35 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 26 Oct 2025 11:37:25 -0400 Subject: [PATCH 3/3] 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 --- src/fastmcp/server/auth/providers/azure.py | 58 +++++++++++------ tests/server/auth/providers/test_azure.py | 75 +++++++++++++++------- 2 files changed, 89 insertions(+), 44 deletions(-) diff --git a/src/fastmcp/server/auth/providers/azure.py b/src/fastmcp/server/auth/providers/azure.py index 7894272f2..163585046 100644 --- a/src/fastmcp/server/auth/providers/azure.py +++ b/src/fastmcp/server/auth/providers/azure.py @@ -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 @@ -217,24 +217,19 @@ def __init__( 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 @@ -305,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) diff --git a/tests/server/auth/providers/test_azure.py b/tests/server/auth/providers/test_azure.py index 3442ac7aa..b4b7428de 100644 --- a/tests/server/auth/providers/test_azure.py +++ b/tests/server/auth/providers/test_azure.py @@ -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: @@ -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) @@ -212,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): @@ -232,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", @@ -250,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", @@ -283,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", @@ -299,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( @@ -337,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", ) @@ -350,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