diff --git a/docs/integrations/azure.mdx b/docs/integrations/azure.mdx index 90dcfd1bc9..e18843abf6 100644 --- a/docs/integrations/azure.mdx +++ b/docs/integrations/azure.mdx @@ -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. +### 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` | ✗ | + + +**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. + + + +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. + + ## Testing ### Running the Server @@ -304,6 +332,8 @@ Redirect path configured in your Azure App registration 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). + Azure's OAuth API requires the `scope` parameter - you must provide at least one scope. diff --git a/src/fastmcp/server/auth/providers/azure.py b/src/fastmcp/server/auth/providers/azure.py index c216e351aa..591919fa8a 100644 --- a/src/fastmcp/server/auth/providers/azure.py +++ b/src/fastmcp/server/auth/providers/azure.py @@ -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.""" @@ -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 @@ -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 = "" @@ -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 @@ -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 diff --git a/tests/server/auth/providers/test_azure.py b/tests/server/auth/providers/test_azure.py index 70098725a7..b8ead4655c 100644 --- a/tests/server/auth/providers/test_azure.py +++ b/tests/server/auth/providers/test_azure.py @@ -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: @@ -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", @@ -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 @@ -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): @@ -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