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
44 changes: 44 additions & 0 deletions docs/integrations/azure.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -282,3 +282,47 @@ Parameters (`jwt_signing_key` and `client_storage`) work together to ensure toke

For complete details on these parameters, see the [OAuth Proxy documentation](/servers/auth/oauth-proxy#configuration-parameters).
</Note>

## Token Verification Only (Managed Identity)

<VersionBadge version="2.15.0" />

For deployments where your server only needs to **validate incoming tokens** — such as Azure Container Apps with Managed Identity — use `AzureJWTVerifier` with `RemoteAuthProvider` instead of the full `AzureProvider`.

This pattern is ideal when:
- Your infrastructure handles authentication (e.g., Managed Identity)
- You don't need the OAuth proxy flow (no `client_secret` required)
- You just need to verify that incoming Azure AD tokens are valid

```python server.py
from fastmcp import FastMCP
from fastmcp.server.auth import RemoteAuthProvider
from fastmcp.server.auth.providers.azure import AzureJWTVerifier
from pydantic import AnyHttpUrl

tenant_id = "your-tenant-id"
client_id = "your-client-id"

# AzureJWTVerifier auto-configures JWKS, issuer, and audience
verifier = AzureJWTVerifier(
client_id=client_id,
tenant_id=tenant_id,
required_scopes=["access_as_user"], # Scope names from Azure Portal
)

auth = RemoteAuthProvider(
token_verifier=verifier,
authorization_servers=[
AnyHttpUrl(f"https://login.microsoftonline.com/{tenant_id}/v2.0")
],
base_url="https://your-container-app.azurecontainerapps.io",
)

mcp = FastMCP(name="Azure MI App", auth=auth)
```

`AzureJWTVerifier` handles Azure's scope format automatically. You write scope names exactly as they appear in Azure Portal under **Expose an API** (e.g., `access_as_user`). The verifier validates tokens using the short-form scopes that Azure puts in the `scp` claim, while advertising the full URI scopes (e.g., `api://your-client-id/access_as_user`) in OAuth metadata so MCP clients know what to request.

<Note>
For Azure Government, pass `base_authority="login.microsoftonline.us"` to `AzureJWTVerifier`.
</Note>
Comment thread
jlowin marked this conversation as resolved.
15 changes: 15 additions & 0 deletions docs/servers/auth/remote-oauth.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,21 @@ This configuration creates a server that accepts tokens issued by `auth.yourcomp

The `authorization_servers` list tells MCP clients which identity providers you trust. The `base_url` identifies your server in OAuth metadata, enabling proper token audience validation. **Important**: The `base_url` should point to your server base URL - for example, if your MCP server is accessible at `https://api.yourcompany.com/mcp`, use `https://api.yourcompany.com` as the base URL.

### Overriding Advertised Scopes

Some identity providers use different scope formats for authorization requests versus token claims. For example, Azure AD requires clients to request full URI scopes like `api://client-id/read`, but the token's `scp` claim contains just `read`. The `scopes_supported` parameter lets you advertise the full-form scopes in metadata while validating against the short form:

```python
auth = RemoteAuthProvider(
token_verifier=token_verifier,
authorization_servers=[AnyHttpUrl("https://auth.example.com")],
base_url="https://api.example.com",
scopes_supported=["api://my-api/read", "api://my-api/write"],
)
```

When not set, `scopes_supported` defaults to the token verifier's `required_scopes`. For Azure AD specifically, see the [AzureJWTVerifier](/integrations/azure#token-verification-only-managed-identity) which handles this automatically.
Comment thread
jlowin marked this conversation as resolved.

### Custom Endpoints

You can extend `RemoteAuthProvider` to add additional endpoints beyond the standard OAuth protected resource metadata. These don't have to be OAuth-specific - you can add any endpoints your authentication integration requires.
Expand Down
23 changes: 22 additions & 1 deletion src/fastmcp/server/auth/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,17 @@ def __init__(
"""
super().__init__(base_url=base_url, required_scopes=required_scopes)

@property
def scopes_supported(self) -> list[str]:
"""Scopes to advertise in OAuth metadata.

Defaults to required_scopes. Override in subclasses when the
advertised scopes differ from the validation scopes (e.g., Azure AD
where tokens contain short-form scopes but clients request full URI
scopes).
"""
return self.required_scopes or []

async def verify_token(self, token: str) -> AccessToken | None:
"""Verify a bearer token and return access info if valid."""
raise NotImplementedError("Subclasses must implement verify_token")
Expand All @@ -299,6 +310,7 @@ def __init__(
token_verifier: TokenVerifier,
authorization_servers: list[AnyHttpUrl],
base_url: AnyHttpUrl | str,
scopes_supported: list[str] | None = None,
resource_name: str | None = None,
resource_documentation: AnyHttpUrl | None = None,
):
Expand All @@ -308,6 +320,10 @@ def __init__(
token_verifier: TokenVerifier instance for token validation
authorization_servers: List of authorization servers that issue valid tokens
base_url: The base URL of this server
scopes_supported: Scopes to advertise in OAuth metadata. If None,
uses the token verifier's scopes_supported property. Use this
when the scopes clients request differ from the scopes that
appear in tokens (e.g., Azure AD full URI scopes vs short-form).
resource_name: Optional name for the protected resource
resource_documentation: Optional documentation URL for the protected resource
"""
Expand All @@ -317,6 +333,7 @@ def __init__(
)
self.token_verifier = token_verifier
self.authorization_servers = authorization_servers
self._scopes_supported = scopes_supported
self.resource_name = resource_name
self.resource_documentation = resource_documentation

Expand All @@ -343,7 +360,11 @@ def get_routes(
create_protected_resource_routes(
resource_url=resource_url,
authorization_servers=self.authorization_servers,
scopes_supported=self.token_verifier.required_scopes,
scopes_supported=(
self._scopes_supported
if self._scopes_supported is not None
else self.token_verifier.scopes_supported
),
resource_name=self.resource_name,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
resource_documentation=self.resource_documentation,
)
Expand Down
100 changes: 100 additions & 0 deletions src/fastmcp/server/auth/providers/azure.py
Original file line number Diff line number Diff line change
Expand Up @@ -452,3 +452,103 @@ async def _extract_upstream_claims(
except Exception as e:
logger.debug("Failed to extract Azure claims: %s", e)
return None


class AzureJWTVerifier(JWTVerifier):
"""JWT verifier pre-configured for Azure AD / Microsoft Entra ID.

Auto-configures JWKS URI, issuer, audience, and scope handling from your
Azure app registration details. Designed for Managed Identity and other
token-verification-only scenarios where AzureProvider's full OAuth proxy
isn't needed.

Handles Azure's scope format automatically:
- Validates tokens using short-form scopes (what Azure puts in ``scp`` claims)
- Advertises full-URI scopes in OAuth metadata (what clients need to request)

Example::

from fastmcp.server.auth import RemoteAuthProvider
from fastmcp.server.auth.providers.azure import AzureJWTVerifier
from pydantic import AnyHttpUrl

verifier = AzureJWTVerifier(
client_id="your-client-id",
tenant_id="your-tenant-id",
required_scopes=["access_as_user"],
)

auth = RemoteAuthProvider(
token_verifier=verifier,
authorization_servers=[
AnyHttpUrl("https://login.microsoftonline.com/your-tenant-id/v2.0")
],
base_url="https://my-server.com",
)
"""

def __init__(
self,
*,
client_id: str,
tenant_id: str,
required_scopes: list[str] | None = None,
identifier_uri: str | None = None,
base_authority: str = "login.microsoftonline.com",
):
"""Initialize Azure JWT verifier.

Args:
client_id: Azure application (client) ID from your App registration
tenant_id: Azure tenant ID (specific tenant GUID, "organizations", or "consumers").
For multi-tenant apps ("organizations" or "consumers"), issuer validation
is skipped since Azure tokens carry the actual tenant GUID as issuer.
required_scopes: Scope names as they appear in Azure Portal under "Expose an API"
(e.g., ["access_as_user", "read"]). These are validated against
the short-form scopes in token ``scp`` claims, and automatically
prefixed with identifier_uri for OAuth metadata.
identifier_uri: Application ID URI (defaults to ``api://{client_id}``).
Used to prefix scopes in OAuth metadata so clients know the full
scope URIs to request from Azure.
base_authority: Azure authority base URL (defaults to "login.microsoftonline.com").
For Azure Government, use "login.microsoftonline.us".
"""
self._identifier_uri = identifier_uri or f"api://{client_id}"

# For multi-tenant apps, Azure tokens carry the actual tenant GUID as
# issuer, not the literal "organizations" or "consumers" string. Skip
# issuer validation for these — audience still protects against wrong-app tokens.
multi_tenant_values = {"organizations", "consumers", "common"}
issuer: str | None = (
None
if tenant_id in multi_tenant_values
else f"https://{base_authority}/{tenant_id}/v2.0"
)

super().__init__(
jwks_uri=f"https://{base_authority}/{tenant_id}/discovery/v2.0/keys",
issuer=issuer,
audience=client_id,
Comment thread
jlowin marked this conversation as resolved.
algorithm="RS256",
required_scopes=required_scopes,
)
Comment thread
jlowin marked this conversation as resolved.

@property
def scopes_supported(self) -> list[str]:
"""Return scopes with Azure URI prefix for OAuth metadata.

Azure tokens contain short-form scopes (e.g., ``read``) in the ``scp``
claim, but clients must request full URI scopes (e.g.,
``api://client-id/read``) from the Azure authorization endpoint. This
property returns the full-URI form for OAuth metadata while
``required_scopes`` retains the short form for token validation.
"""
if not self.required_scopes:
return []
prefixed = []
for scope in self.required_scopes:
if scope in OIDC_SCOPES or "://" in scope or "/" in scope:
prefixed.append(scope)
else:
prefixed.append(f"{self._identifier_uri}/{scope}")
return prefixed
143 changes: 141 additions & 2 deletions tests/server/auth/providers/test_azure.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,12 @@
from mcp.shared.auth import OAuthClientInformationFull
from pydantic import AnyUrl

from fastmcp.server.auth.providers.azure import OIDC_SCOPES, AzureProvider
from fastmcp.server.auth.providers.jwt import JWTVerifier
from fastmcp.server.auth.providers.azure import (
OIDC_SCOPES,
AzureJWTVerifier,
AzureProvider,
)
from fastmcp.server.auth.providers.jwt import JWTVerifier, RSAKeyPair


class TestAzureProvider:
Expand Down Expand Up @@ -983,3 +987,138 @@ def test_extra_token_params_does_not_contain_scope(self):
["read", "write"]
)
assert len(refresh_scopes) > 0


class TestAzureJWTVerifier:
"""Tests for AzureJWTVerifier pre-configured JWT verifier."""

def test_auto_configures_from_client_and_tenant(self):
verifier = AzureJWTVerifier(
client_id="my-client-id",
tenant_id="my-tenant-id",
required_scopes=["access_as_user"],
)
assert (
verifier.jwks_uri
== "https://login.microsoftonline.com/my-tenant-id/discovery/v2.0/keys"
)
assert verifier.issuer == "https://login.microsoftonline.com/my-tenant-id/v2.0"
assert verifier.audience == "my-client-id"
assert verifier.algorithm == "RS256"
assert verifier.required_scopes == ["access_as_user"]

async def test_validates_short_form_scopes(self):
key_pair = RSAKeyPair.generate()
verifier = AzureJWTVerifier(
client_id="my-client-id",
tenant_id="my-tenant-id",
required_scopes=["access_as_user"],
)
# Override to use our test key instead of JWKS
verifier.public_key = key_pair.public_key
verifier.jwks_uri = None

token = key_pair.create_token(
subject="test-user",
issuer="https://login.microsoftonline.com/my-tenant-id/v2.0",
audience="my-client-id",
additional_claims={"scp": "access_as_user"},
)
result = await verifier.load_access_token(token)
assert result is not None
assert "access_as_user" in result.scopes

def test_scopes_supported_returns_prefixed_form(self):
verifier = AzureJWTVerifier(
client_id="my-client-id",
tenant_id="my-tenant-id",
required_scopes=["read", "write"],
)
assert verifier.scopes_supported == [
"api://my-client-id/read",
"api://my-client-id/write",
]

def test_already_prefixed_scopes_pass_through(self):
verifier = AzureJWTVerifier(
client_id="my-client-id",
tenant_id="my-tenant-id",
required_scopes=["api://my-client-id/read"],
)
assert verifier.scopes_supported == ["api://my-client-id/read"]

def test_oidc_scopes_not_prefixed(self):
verifier = AzureJWTVerifier(
client_id="my-client-id",
tenant_id="my-tenant-id",
required_scopes=["openid", "read"],
)
assert verifier.scopes_supported == ["openid", "api://my-client-id/read"]

def test_custom_identifier_uri(self):
verifier = AzureJWTVerifier(
client_id="my-client-id",
tenant_id="my-tenant-id",
required_scopes=["read"],
identifier_uri="api://custom-uri",
)
assert verifier.scopes_supported == ["api://custom-uri/read"]

def test_custom_base_authority_for_gov_cloud(self):
verifier = AzureJWTVerifier(
client_id="my-client-id",
tenant_id="my-tenant-id",
required_scopes=["read"],
base_authority="login.microsoftonline.us",
)
assert (
verifier.jwks_uri
== "https://login.microsoftonline.us/my-tenant-id/discovery/v2.0/keys"
)
assert verifier.issuer == "https://login.microsoftonline.us/my-tenant-id/v2.0"

def test_scopes_supported_empty_when_no_required_scopes(self):
verifier = AzureJWTVerifier(
client_id="my-client-id",
tenant_id="my-tenant-id",
)
assert verifier.scopes_supported == []

def test_default_identifier_uri_uses_client_id(self):
verifier = AzureJWTVerifier(
client_id="abc-123",
tenant_id="my-tenant-id",
required_scopes=["read"],
)
assert verifier.scopes_supported == ["api://abc-123/read"]

def test_multi_tenant_organizations_skips_issuer(self):
verifier = AzureJWTVerifier(
client_id="my-client-id",
tenant_id="organizations",
)
assert verifier.issuer is None

def test_multi_tenant_consumers_skips_issuer(self):
verifier = AzureJWTVerifier(
client_id="my-client-id",
tenant_id="consumers",
)
assert verifier.issuer is None

def test_multi_tenant_common_skips_issuer(self):
verifier = AzureJWTVerifier(
client_id="my-client-id",
tenant_id="common",
)
assert verifier.issuer is None

def test_specific_tenant_sets_issuer(self):
verifier = AzureJWTVerifier(
client_id="my-client-id",
tenant_id="12345678-1234-1234-1234-123456789012",
)
assert (
verifier.issuer
== "https://login.microsoftonline.com/12345678-1234-1234-1234-123456789012/v2.0"
)
Loading
Loading