Fix Azure provider OIDC scope handling (#2506)

* Fix Azure provider to handle OIDC scopes correctly

OIDC scopes (openid, profile, email, offline_access) were being
incorrectly prefixed with identifier_uri, causing Azure to reject
authorization requests. This fix:

- Detects OIDC scopes and sends them unprefixed to Azure
- Filters OIDC scopes from token validation (Azure doesn't include
  them in access token scp claims)
- Still advertises OIDC scopes to clients via valid_scopes
- Also handles dot-notation scopes (e.g., User.Read) correctly

Fixes #2451, #2420

* Fix dot-notation scopes to be prefixed (custom scopes can have dots)

* Improve Azure scope handling docs with clear examples
This commit is contained in:
Jeremiah Lowin 2025-12-01 13:42:36 -05:00 committed by GitHub
commit 9c21754a45
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 243 additions and 11 deletions

View file

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

View file

@ -25,6 +25,11 @@ if TYPE_CHECKING:
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 @@ class AzureProvider(OAuthProxy):
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 @@ class AzureProvider(OAuthProxy):
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 @@ class AzureProvider(OAuthProxy):
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 @@ class AzureProvider(OAuthProxy):
"""
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

View file

@ -9,7 +9,7 @@ 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.azure import OIDC_SCOPES, AzureProvider
class TestAzureProvider:
@ -288,7 +288,7 @@ class TestAzureProvider:
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 @@ class TestAzureProvider:
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 @@ class TestAzureProvider:
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 @@ class TestAzureProvider:
# 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