mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 15:19:10 +02:00
Add Azure OBO dependencies, auth token injection, and documentation (#2918)
This commit is contained in:
parent
dfb857aa08
commit
45af482e73
9 changed files with 688 additions and 46 deletions
|
|
@ -326,3 +326,135 @@ mcp = FastMCP(name="Azure MI App", auth=auth)
|
|||
<Note>
|
||||
For Azure Government, pass `base_authority="login.microsoftonline.us"` to `AzureJWTVerifier`.
|
||||
</Note>
|
||||
|
||||
## On-Behalf-Of (OBO)
|
||||
|
||||
<VersionBadge version="3.0.0" />
|
||||
|
||||
The On-Behalf-Of (OBO) flow allows your FastMCP server to call downstream Microsoft APIs—like Microsoft Graph—using the authenticated user's identity. When a user authenticates to your MCP server, you receive a token for your API. OBO exchanges that token for a new token that can call other services, maintaining the user's identity and permissions throughout the chain.
|
||||
|
||||
This pattern is useful when your tools need to access user-specific data from Microsoft services: reading emails, accessing calendar events, querying SharePoint, or any other Graph API operation that requires user context.
|
||||
|
||||
<Note>
|
||||
OBO features require the `azure` extra:
|
||||
|
||||
```bash
|
||||
pip install 'fastmcp[azure]'
|
||||
```
|
||||
</Note>
|
||||
|
||||
### Azure Portal Setup
|
||||
|
||||
OBO requires additional configuration in your Azure App registration beyond basic authentication.
|
||||
|
||||
<Steps>
|
||||
<Step title="Add API Permissions">
|
||||
In your App registration, navigate to **API permissions** and add the Microsoft Graph permissions your tools will need.
|
||||
|
||||
- Click **Add a permission** → **Microsoft Graph** → **Delegated permissions**
|
||||
- Select the permissions required for your use case (e.g., `Mail.Read`, `Calendars.Read`, `User.Read`)
|
||||
- Repeat for any other APIs you need to call
|
||||
|
||||
<Warning>
|
||||
Only add delegated permissions for OBO. Application permissions bypass user context entirely and are inappropriate for the OBO flow.
|
||||
</Warning>
|
||||
</Step>
|
||||
|
||||
<Step title="Grant Admin Consent">
|
||||
OBO requires admin consent for the permissions you've added. In the **API permissions** page, click **Grant admin consent for [Your Organization]**.
|
||||
|
||||
Without admin consent, OBO token exchanges will fail with an `AADSTS65001` error indicating the user or administrator hasn't consented to use the application.
|
||||
|
||||
<Tip>
|
||||
For development, you can grant consent for just your own account. For production, an Azure AD administrator must grant tenant-wide consent.
|
||||
</Tip>
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
### Configure AzureProvider for OBO
|
||||
|
||||
The `additional_authorize_scopes` parameter tells Azure which downstream API permissions to include during the initial authorization. These scopes establish what your server can request through OBO later.
|
||||
|
||||
```python server.py
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.azure import AzureProvider
|
||||
|
||||
auth_provider = AzureProvider(
|
||||
client_id="your-client-id",
|
||||
client_secret="your-client-secret",
|
||||
tenant_id="your-tenant-id",
|
||||
base_url="http://localhost:8000",
|
||||
required_scopes=["mcp-access"], # Your API scope
|
||||
# Include Graph scopes for OBO
|
||||
additional_authorize_scopes=[
|
||||
"https://graph.microsoft.com/Mail.Read",
|
||||
"https://graph.microsoft.com/User.Read",
|
||||
"offline_access", # Enables refresh tokens
|
||||
],
|
||||
)
|
||||
|
||||
mcp = FastMCP(name="Graph-Enabled Server", auth=auth_provider)
|
||||
```
|
||||
|
||||
Scopes listed in `additional_authorize_scopes` are requested during the initial OAuth flow but aren't validated on incoming tokens. They establish permission for your server to later exchange the user's token for downstream API access.
|
||||
|
||||
<Info>
|
||||
Use fully-qualified scope URIs for downstream APIs (e.g., `https://graph.microsoft.com/Mail.Read`). Short forms like `Mail.Read` work for authorization requests, but fully-qualified URIs are clearer and avoid ambiguity.
|
||||
</Info>
|
||||
|
||||
### EntraOBOToken Dependency
|
||||
|
||||
The `EntraOBOToken` dependency handles the complete OBO flow automatically. Declare it as a parameter default with the scopes you need, and FastMCP exchanges the user's token for a downstream API token before your function runs.
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.azure import AzureProvider, EntraOBOToken
|
||||
import httpx
|
||||
|
||||
auth_provider = AzureProvider(
|
||||
client_id="your-client-id",
|
||||
client_secret="your-client-secret",
|
||||
tenant_id="your-tenant-id",
|
||||
base_url="http://localhost:8000",
|
||||
required_scopes=["mcp-access"],
|
||||
additional_authorize_scopes=[
|
||||
"https://graph.microsoft.com/Mail.Read",
|
||||
"https://graph.microsoft.com/User.Read",
|
||||
],
|
||||
)
|
||||
|
||||
mcp = FastMCP(name="Email Reader", auth=auth_provider)
|
||||
|
||||
@mcp.tool
|
||||
async def get_recent_emails(
|
||||
count: int = 10,
|
||||
graph_token: str = EntraOBOToken(["https://graph.microsoft.com/Mail.Read"]),
|
||||
) -> list[dict]:
|
||||
"""Get the user's recent emails from Microsoft Graph."""
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
f"https://graph.microsoft.com/v1.0/me/messages?$top={count}",
|
||||
headers={"Authorization": f"Bearer {graph_token}"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
return [
|
||||
{"subject": msg["subject"], "from": msg["from"]["emailAddress"]["address"]}
|
||||
for msg in data.get("value", [])
|
||||
]
|
||||
```
|
||||
|
||||
The `graph_token` parameter receives a ready-to-use access token for Microsoft Graph. FastMCP handles the OBO exchange transparently—your function just uses the token to call the API.
|
||||
|
||||
<Warning>
|
||||
**Scope alignment is critical.** The scopes passed to `EntraOBOToken` must be a subset of the scopes in `additional_authorize_scopes`. If you request a scope during OBO that wasn't included in the initial authorization, the exchange will fail.
|
||||
</Warning>
|
||||
|
||||
<Tip>
|
||||
For advanced OBO scenarios, use `CurrentAccessToken()` to get the user's token, then construct an `azure.identity.aio.OnBehalfOfCredential` directly with your Azure credentials.
|
||||
</Tip>
|
||||
|
||||
<Tip>
|
||||
For a complete working example of Azure OBO with FastMCP, see [Pamela Fox's blog post on OBO flow for Entra-based MCP servers](https://blog.pamelafox.org/2026/01/using-on-behalf-of-flow-for-entra-based.html).
|
||||
</Tip>
|
||||
|
|
|
|||
|
|
@ -237,6 +237,37 @@ The `AccessToken` object provides:
|
|||
- **`expires_at`**: Token expiration timestamp (if available)
|
||||
- **`claims`**: Dictionary of all token claims (JWT claims or provider-specific data)
|
||||
|
||||
### Token Claims
|
||||
|
||||
When you need just one specific value from the token—like a user ID or tenant identifier—`TokenClaim()` extracts it directly without needing the full token object.
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.dependencies import TokenClaim
|
||||
|
||||
mcp = FastMCP("Demo")
|
||||
|
||||
|
||||
@mcp.tool
|
||||
async def add_expense(
|
||||
amount: float,
|
||||
user_id: str = TokenClaim("oid"), # Azure object ID
|
||||
) -> dict:
|
||||
await db.insert({"user_id": user_id, "amount": amount})
|
||||
return {"status": "created", "user_id": user_id}
|
||||
```
|
||||
|
||||
`TokenClaim()` raises a `RuntimeError` if the claim doesn't exist, listing available claims to help with debugging.
|
||||
|
||||
Common claims vary by identity provider:
|
||||
|
||||
| Provider | User ID Claim | Email Claim | Name Claim |
|
||||
|----------|--------------|-------------|------------|
|
||||
| Azure/Entra | `oid` | `email` | `name` |
|
||||
| GitHub | `sub` | `email` | `name` |
|
||||
| Google | `sub` | `email` | `name` |
|
||||
| Auth0 | `sub` | `email` | `name` |
|
||||
|
||||
### Background Task Dependencies
|
||||
|
||||
<VersionBadge version="2.3.0" />
|
||||
|
|
|
|||
|
|
@ -52,13 +52,14 @@ classifiers = [
|
|||
|
||||
[project.optional-dependencies]
|
||||
anthropic = ["anthropic>=0.40.0"]
|
||||
azure = ["azure-identity>=1.16.0"]
|
||||
openai = ["openai>=1.102.0"]
|
||||
tasks = ["pydocket>=0.17.2"]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"dirty-equals>=0.9.0",
|
||||
"fastmcp[anthropic,openai,tasks]",
|
||||
"fastmcp[anthropic,azure,openai,tasks]",
|
||||
# add optional dependencies for fastmcp dev
|
||||
"fastapi>=0.115.12",
|
||||
"opentelemetry-sdk>=1.20.0",
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ from fastmcp.server.dependencies import (
|
|||
CurrentWorker,
|
||||
Progress,
|
||||
ProgressLike,
|
||||
TokenClaim,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
|
|
@ -39,4 +40,5 @@ __all__ = [
|
|||
"Depends",
|
||||
"Progress",
|
||||
"ProgressLike",
|
||||
"TokenClaim",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ using the OAuth Proxy pattern for non-DCR OAuth flows.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from key_value.aio.protocols import AsyncKeyValue
|
||||
|
||||
|
|
@ -16,6 +16,7 @@ from fastmcp.utilities.auth import decode_jwt_payload, parse_scopes
|
|||
from fastmcp.utilities.logging import get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from azure.identity.aio import OnBehalfOfCredential
|
||||
from mcp.server.auth.provider import AuthorizationParams
|
||||
from mcp.shared.auth import OAuthClientInformationFull
|
||||
|
||||
|
|
@ -161,6 +162,10 @@ class AzureProvider(OAuthProxy):
|
|||
if "offline_access" not in parsed_additional_scopes:
|
||||
parsed_additional_scopes = [*parsed_additional_scopes, "offline_access"]
|
||||
|
||||
# Store Azure-specific config for OBO credential creation
|
||||
self._tenant_id = tenant_id
|
||||
self._base_authority = base_authority
|
||||
|
||||
# Apply defaults
|
||||
self.identifier_uri = identifier_uri or f"api://{client_id}"
|
||||
self.additional_authorize_scopes: list[str] = parsed_additional_scopes
|
||||
|
|
@ -453,6 +458,33 @@ class AzureProvider(OAuthProxy):
|
|||
logger.debug("Failed to extract Azure claims: %s", e)
|
||||
return None
|
||||
|
||||
def create_obo_credential(self, user_assertion: str) -> OnBehalfOfCredential:
|
||||
"""Create an OnBehalfOfCredential for OBO token exchange.
|
||||
|
||||
Uses the AzureProvider's configuration (client_id, client_secret,
|
||||
tenant_id, authority) to create a credential that can exchange the
|
||||
user's token for downstream API tokens.
|
||||
|
||||
Args:
|
||||
user_assertion: The user's access token to exchange via OBO.
|
||||
|
||||
Returns:
|
||||
A configured OnBehalfOfCredential ready for get_token() calls.
|
||||
|
||||
Raises:
|
||||
ImportError: If azure-identity is not installed (requires fastmcp[azure]).
|
||||
"""
|
||||
_require_azure_identity("OBO token exchange")
|
||||
from azure.identity.aio import OnBehalfOfCredential
|
||||
|
||||
return OnBehalfOfCredential(
|
||||
tenant_id=self._tenant_id,
|
||||
client_id=self._upstream_client_id,
|
||||
client_secret=self._upstream_client_secret.get_secret_value(),
|
||||
user_assertion=user_assertion,
|
||||
authority=f"https://{self._base_authority}",
|
||||
)
|
||||
|
||||
|
||||
class AzureJWTVerifier(JWTVerifier):
|
||||
"""JWT verifier pre-configured for Azure AD / Microsoft Entra ID.
|
||||
|
|
@ -552,3 +584,117 @@ class AzureJWTVerifier(JWTVerifier):
|
|||
else:
|
||||
prefixed.append(f"{self._identifier_uri}/{scope}")
|
||||
return prefixed
|
||||
|
||||
|
||||
# --- Dependency injection support ---
|
||||
# These require fastmcp[azure] extra for azure-identity
|
||||
|
||||
# Check if DI engine is available
|
||||
try:
|
||||
from docket.dependencies import Dependency
|
||||
except ImportError:
|
||||
from fastmcp._vendor.docket_di import Dependency
|
||||
|
||||
|
||||
def _require_azure_identity(feature: str) -> None:
|
||||
"""Raise ImportError with install instructions if azure-identity is not available."""
|
||||
try:
|
||||
import azure.identity # noqa: F401
|
||||
except ImportError as e:
|
||||
raise ImportError(
|
||||
f"{feature} requires the `azure` extra. "
|
||||
"Install with: pip install 'fastmcp[azure]'"
|
||||
) from e
|
||||
|
||||
|
||||
class _EntraOBOToken(Dependency): # type: ignore[misc]
|
||||
"""Dependency that performs OBO token exchange for Microsoft Entra.
|
||||
|
||||
Uses azure.identity's OnBehalfOfCredential for async-native OBO,
|
||||
with automatic token caching and refresh.
|
||||
"""
|
||||
|
||||
def __init__(self, scopes: list[str]):
|
||||
self.scopes = scopes
|
||||
self._credential: OnBehalfOfCredential | None = None
|
||||
|
||||
async def __aenter__(self) -> str:
|
||||
_require_azure_identity("EntraOBOToken")
|
||||
|
||||
from fastmcp.server.dependencies import get_access_token, get_server
|
||||
|
||||
access_token = get_access_token()
|
||||
if access_token is None:
|
||||
raise RuntimeError(
|
||||
"No access token available. Cannot perform OBO exchange."
|
||||
)
|
||||
|
||||
server = get_server()
|
||||
if not isinstance(server.auth, AzureProvider):
|
||||
raise RuntimeError(
|
||||
"EntraOBOToken requires an AzureProvider as the auth provider. "
|
||||
f"Current provider: {type(server.auth).__name__}"
|
||||
)
|
||||
|
||||
self._credential = server.auth.create_obo_credential(
|
||||
user_assertion=access_token.token,
|
||||
)
|
||||
|
||||
try:
|
||||
result = await self._credential.get_token(*self.scopes)
|
||||
except BaseException:
|
||||
await self._credential.close()
|
||||
self._credential = None
|
||||
raise
|
||||
|
||||
return result.token
|
||||
|
||||
async def __aexit__(self, *args: object) -> None:
|
||||
if self._credential is not None:
|
||||
await self._credential.close()
|
||||
self._credential = None
|
||||
|
||||
|
||||
def EntraOBOToken(scopes: list[str]) -> str:
|
||||
"""Exchange the user's Entra token for a downstream API token via OBO.
|
||||
|
||||
This dependency performs a Microsoft Entra On-Behalf-Of (OBO) token exchange,
|
||||
allowing your MCP server to call downstream APIs (like Microsoft Graph) on
|
||||
behalf of the authenticated user.
|
||||
|
||||
Args:
|
||||
scopes: The scopes to request for the downstream API. For Microsoft Graph,
|
||||
use scopes like ["https://graph.microsoft.com/Mail.Read"] or
|
||||
["https://graph.microsoft.com/.default"].
|
||||
|
||||
Returns:
|
||||
A dependency that resolves to the downstream API access token string
|
||||
|
||||
Raises:
|
||||
ImportError: If fastmcp[azure] is not installed
|
||||
RuntimeError: If no access token is available, provider is not Azure,
|
||||
or OBO exchange fails
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastmcp.server.auth.providers.azure import EntraOBOToken
|
||||
import httpx
|
||||
|
||||
@mcp.tool()
|
||||
async def get_my_emails(
|
||||
graph_token: str = EntraOBOToken(["https://graph.microsoft.com/Mail.Read"])
|
||||
):
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(
|
||||
"https://graph.microsoft.com/v1.0/me/messages",
|
||||
headers={"Authorization": f"Bearer {graph_token}"}
|
||||
)
|
||||
return resp.json()
|
||||
```
|
||||
|
||||
Note:
|
||||
For OBO to work, ensure the scopes are included in the AzureProvider's
|
||||
`additional_authorize_scopes` parameter, and that admin consent has been
|
||||
granted for those scopes in your Entra app registration.
|
||||
"""
|
||||
return cast(str, _EntraOBOToken(scopes))
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ __all__ = [
|
|||
"CurrentWorker",
|
||||
"Progress",
|
||||
"TaskContextInfo",
|
||||
"TokenClaim",
|
||||
"get_access_token",
|
||||
"get_context",
|
||||
"get_http_headers",
|
||||
|
|
@ -991,47 +992,6 @@ def CurrentHeaders() -> dict[str, str]:
|
|||
return cast(dict[str, str], _CurrentHeaders())
|
||||
|
||||
|
||||
class _CurrentAccessToken(Dependency): # type: ignore[misc]
|
||||
"""Async context manager for AccessToken dependency."""
|
||||
|
||||
async def __aenter__(self) -> AccessToken:
|
||||
token = get_access_token()
|
||||
if token is None:
|
||||
raise RuntimeError(
|
||||
"No access token found. Ensure authentication is configured "
|
||||
"and the request is authenticated."
|
||||
)
|
||||
return token
|
||||
|
||||
async def __aexit__(self, *args: object) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def CurrentAccessToken() -> AccessToken:
|
||||
"""Get the current access token for the authenticated user.
|
||||
|
||||
This dependency provides access to the AccessToken for the current
|
||||
authenticated request. Raises an error if no authentication is present.
|
||||
|
||||
Returns:
|
||||
A dependency that resolves to the active AccessToken
|
||||
|
||||
Raises:
|
||||
RuntimeError: If no authenticated user (use get_access_token() for optional)
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastmcp.server.dependencies import CurrentAccessToken
|
||||
from fastmcp.server.auth import AccessToken
|
||||
|
||||
@mcp.tool()
|
||||
async def get_user_id(token: AccessToken = CurrentAccessToken()) -> str:
|
||||
return token.claims.get("sub", "unknown")
|
||||
```
|
||||
"""
|
||||
return cast(AccessToken, _CurrentAccessToken())
|
||||
|
||||
|
||||
# --- Progress dependency ---
|
||||
|
||||
|
||||
|
|
@ -1162,3 +1122,106 @@ class Progress(Dependency): # type: ignore[misc]
|
|||
|
||||
async def __aexit__(self, *args: object) -> None:
|
||||
pass
|
||||
|
||||
|
||||
# --- Access Token dependency ---
|
||||
|
||||
|
||||
class _CurrentAccessToken(Dependency): # type: ignore[misc]
|
||||
"""Async context manager for AccessToken dependency."""
|
||||
|
||||
async def __aenter__(self) -> AccessToken:
|
||||
token = get_access_token()
|
||||
if token is None:
|
||||
raise RuntimeError(
|
||||
"No access token found. Ensure authentication is configured "
|
||||
"and the request is authenticated."
|
||||
)
|
||||
return token
|
||||
|
||||
async def __aexit__(self, *args: object) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def CurrentAccessToken() -> AccessToken:
|
||||
"""Get the current access token for the authenticated user.
|
||||
|
||||
This dependency provides access to the AccessToken for the current
|
||||
authenticated request. Raises an error if no authentication is present.
|
||||
|
||||
Returns:
|
||||
A dependency that resolves to the active AccessToken
|
||||
|
||||
Raises:
|
||||
RuntimeError: If no authenticated user (use get_access_token() for optional)
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastmcp.server.dependencies import CurrentAccessToken
|
||||
from fastmcp.server.auth import AccessToken
|
||||
|
||||
@mcp.tool()
|
||||
async def get_user_id(token: AccessToken = CurrentAccessToken()) -> str:
|
||||
return token.claims.get("sub", "unknown")
|
||||
```
|
||||
"""
|
||||
return cast(AccessToken, _CurrentAccessToken())
|
||||
|
||||
|
||||
# --- Token Claim dependency ---
|
||||
|
||||
|
||||
class _TokenClaim(Dependency): # type: ignore[misc]
|
||||
"""Dependency that extracts a specific claim from the access token."""
|
||||
|
||||
def __init__(self, claim_name: str):
|
||||
self.claim_name = claim_name
|
||||
|
||||
async def __aenter__(self) -> str:
|
||||
token = get_access_token()
|
||||
if token is None:
|
||||
raise RuntimeError(
|
||||
f"No access token available. Cannot extract claim '{self.claim_name}'."
|
||||
)
|
||||
value = token.claims.get(self.claim_name)
|
||||
if value is None:
|
||||
raise RuntimeError(
|
||||
f"Claim '{self.claim_name}' not found in access token. "
|
||||
f"Available claims: {list(token.claims.keys())}"
|
||||
)
|
||||
return str(value)
|
||||
|
||||
async def __aexit__(self, *args: object) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def TokenClaim(name: str) -> str:
|
||||
"""Get a specific claim from the access token.
|
||||
|
||||
This dependency extracts a single claim value from the current access token.
|
||||
It's useful for getting user identifiers, roles, or other token claims
|
||||
without needing the full token object.
|
||||
|
||||
Args:
|
||||
name: The name of the claim to extract (e.g., "oid", "sub", "email")
|
||||
|
||||
Returns:
|
||||
A dependency that resolves to the claim value as a string
|
||||
|
||||
Raises:
|
||||
RuntimeError: If no access token is available or claim is missing
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastmcp.server.dependencies import TokenClaim
|
||||
|
||||
@mcp.tool()
|
||||
async def add_expense(
|
||||
user_id: str = TokenClaim("oid"), # Azure object ID
|
||||
amount: float,
|
||||
):
|
||||
# user_id is automatically injected from the token
|
||||
await db.insert({"user_id": user_id, "amount": amount})
|
||||
```
|
||||
"""
|
||||
return cast(str, _TokenClaim(name))
|
||||
|
|
|
|||
|
|
@ -1122,3 +1122,99 @@ class TestAzureJWTVerifier:
|
|||
verifier.issuer
|
||||
== "https://login.microsoftonline.com/12345678-1234-1234-1234-123456789012/v2.0"
|
||||
)
|
||||
|
||||
|
||||
class TestAzureOBOIntegration:
|
||||
"""Tests for azure.identity OBO integration (create_obo_credential, EntraOBOToken)."""
|
||||
|
||||
def test_create_obo_credential_returns_configured_credential(self):
|
||||
"""Test that create_obo_credential returns a properly configured credential."""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
provider = AzureProvider(
|
||||
client_id="test-client-id",
|
||||
client_secret="test-client-secret",
|
||||
tenant_id="test-tenant-id",
|
||||
base_url="https://myserver.com",
|
||||
required_scopes=["read"],
|
||||
jwt_signing_key="test-secret",
|
||||
)
|
||||
|
||||
mock_credential = MagicMock()
|
||||
with patch(
|
||||
"azure.identity.aio.OnBehalfOfCredential", return_value=mock_credential
|
||||
) as mock_class:
|
||||
credential = provider.create_obo_credential(user_assertion="user-token-123")
|
||||
|
||||
mock_class.assert_called_once_with(
|
||||
tenant_id="test-tenant-id",
|
||||
client_id="test-client-id",
|
||||
client_secret="test-client-secret",
|
||||
user_assertion="user-token-123",
|
||||
authority="https://login.microsoftonline.com",
|
||||
)
|
||||
assert credential is mock_credential
|
||||
|
||||
def test_create_obo_credential_with_custom_authority(self):
|
||||
"""Test that create_obo_credential uses custom base_authority."""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
provider = AzureProvider(
|
||||
client_id="test-client-id",
|
||||
client_secret="test-client-secret",
|
||||
tenant_id="gov-tenant-id",
|
||||
base_url="https://myserver.com",
|
||||
required_scopes=["read"],
|
||||
base_authority="login.microsoftonline.us",
|
||||
jwt_signing_key="test-secret",
|
||||
)
|
||||
|
||||
mock_credential = MagicMock()
|
||||
with patch(
|
||||
"azure.identity.aio.OnBehalfOfCredential", return_value=mock_credential
|
||||
) as mock_class:
|
||||
provider.create_obo_credential(user_assertion="user-token")
|
||||
|
||||
call_kwargs = mock_class.call_args[1]
|
||||
assert call_kwargs["authority"] == "https://login.microsoftonline.us"
|
||||
|
||||
def test_tenant_and_authority_stored_as_attributes(self):
|
||||
"""Test that tenant_id and base_authority are stored for OBO credential creation."""
|
||||
provider = AzureProvider(
|
||||
client_id="test-client-id",
|
||||
client_secret="test-client-secret",
|
||||
tenant_id="my-tenant",
|
||||
base_url="https://myserver.com",
|
||||
required_scopes=["read"],
|
||||
base_authority="login.microsoftonline.us",
|
||||
jwt_signing_key="test-secret",
|
||||
)
|
||||
|
||||
assert provider._tenant_id == "my-tenant"
|
||||
assert provider._base_authority == "login.microsoftonline.us"
|
||||
|
||||
def test_entra_obo_token_is_importable(self):
|
||||
"""Test that EntraOBOToken can be imported."""
|
||||
from fastmcp.server.auth.providers.azure import EntraOBOToken
|
||||
|
||||
assert EntraOBOToken is not None
|
||||
|
||||
def test_entra_obo_token_creates_dependency(self):
|
||||
"""Test that EntraOBOToken creates a dependency with scopes."""
|
||||
from fastmcp.server.auth.providers.azure import EntraOBOToken, _EntraOBOToken
|
||||
|
||||
dep = EntraOBOToken(["https://graph.microsoft.com/User.Read"])
|
||||
assert isinstance(dep, _EntraOBOToken)
|
||||
assert dep.scopes == ["https://graph.microsoft.com/User.Read"]
|
||||
|
||||
def test_entra_obo_token_is_dependency_instance(self):
|
||||
"""Test that EntraOBOToken is a Dependency instance."""
|
||||
try:
|
||||
from docket.dependencies import Dependency
|
||||
except ImportError:
|
||||
from fastmcp._vendor.docket_di import Dependency
|
||||
|
||||
from fastmcp.server.auth.providers.azure import _EntraOBOToken
|
||||
|
||||
dep = _EntraOBOToken(["scope"])
|
||||
assert isinstance(dep, Dependency)
|
||||
|
|
|
|||
|
|
@ -1045,3 +1045,115 @@ class TestVendoredDI:
|
|||
db_dep = deps["db"]
|
||||
assert isinstance(db_dep, _Depends)
|
||||
assert db_dep.dependency is get_db
|
||||
|
||||
|
||||
class TestAuthDependencies:
|
||||
"""Tests for authentication dependencies (CurrentAccessToken, TokenClaim)."""
|
||||
|
||||
def test_current_access_token_is_importable(self):
|
||||
"""Test that CurrentAccessToken can be imported."""
|
||||
from fastmcp.server.dependencies import CurrentAccessToken
|
||||
|
||||
assert CurrentAccessToken is not None
|
||||
|
||||
def test_token_claim_is_importable(self):
|
||||
"""Test that TokenClaim can be imported."""
|
||||
from fastmcp.server.dependencies import TokenClaim
|
||||
|
||||
assert TokenClaim is not None
|
||||
|
||||
def test_current_access_token_is_dependency(self):
|
||||
"""Test that CurrentAccessToken is a Dependency instance."""
|
||||
# Import the Dependency class the same way the code does
|
||||
# (docket if available, vendored otherwise)
|
||||
try:
|
||||
from docket.dependencies import Dependency
|
||||
except ImportError:
|
||||
from fastmcp._vendor.docket_di import Dependency
|
||||
|
||||
from fastmcp.server.dependencies import _CurrentAccessToken
|
||||
|
||||
dep = _CurrentAccessToken()
|
||||
assert isinstance(dep, Dependency)
|
||||
|
||||
def test_token_claim_creates_dependency(self):
|
||||
"""Test that TokenClaim creates a Dependency instance."""
|
||||
# Import the Dependency class the same way the code does
|
||||
try:
|
||||
from docket.dependencies import Dependency
|
||||
except ImportError:
|
||||
from fastmcp._vendor.docket_di import Dependency
|
||||
|
||||
from fastmcp.server.dependencies import TokenClaim, _TokenClaim
|
||||
|
||||
dep = TokenClaim("oid")
|
||||
assert isinstance(dep, _TokenClaim)
|
||||
assert isinstance(dep, Dependency)
|
||||
assert dep.claim_name == "oid"
|
||||
|
||||
async def test_current_access_token_raises_without_token(self):
|
||||
"""Test that CurrentAccessToken raises when no token is available."""
|
||||
from fastmcp.server.dependencies import _CurrentAccessToken
|
||||
|
||||
dep = _CurrentAccessToken()
|
||||
with pytest.raises(RuntimeError, match="No access token found"):
|
||||
await dep.__aenter__()
|
||||
|
||||
async def test_token_claim_raises_without_token(self):
|
||||
"""Test that TokenClaim raises when no token is available."""
|
||||
from fastmcp.server.dependencies import _TokenClaim
|
||||
|
||||
dep = _TokenClaim("oid")
|
||||
with pytest.raises(RuntimeError, match="No access token available"):
|
||||
await dep.__aenter__()
|
||||
|
||||
async def test_current_access_token_excluded_from_tool_schema(self, mcp: FastMCP):
|
||||
"""Test that CurrentAccessToken dependency is excluded from tool schema."""
|
||||
import mcp.types as mcp_types
|
||||
|
||||
from fastmcp.server.auth import AccessToken
|
||||
from fastmcp.server.dependencies import CurrentAccessToken
|
||||
|
||||
@mcp.tool()
|
||||
async def tool_with_token(
|
||||
name: str,
|
||||
token: AccessToken = CurrentAccessToken(),
|
||||
) -> str:
|
||||
return name
|
||||
|
||||
result = await mcp._list_tools_mcp(mcp_types.ListToolsRequest())
|
||||
tool = next(t for t in result.tools if t.name == "tool_with_token")
|
||||
|
||||
assert "name" in tool.inputSchema["properties"]
|
||||
assert "token" not in tool.inputSchema["properties"]
|
||||
|
||||
async def test_token_claim_excluded_from_tool_schema(self, mcp: FastMCP):
|
||||
"""Test that TokenClaim dependency is excluded from tool schema."""
|
||||
import mcp.types as mcp_types
|
||||
|
||||
from fastmcp.server.dependencies import TokenClaim
|
||||
|
||||
@mcp.tool()
|
||||
async def tool_with_claim(
|
||||
name: str,
|
||||
user_id: str = TokenClaim("oid"),
|
||||
) -> str:
|
||||
return name
|
||||
|
||||
result = await mcp._list_tools_mcp(mcp_types.ListToolsRequest())
|
||||
tool = next(t for t in result.tools if t.name == "tool_with_claim")
|
||||
|
||||
assert "name" in tool.inputSchema["properties"]
|
||||
assert "user_id" not in tool.inputSchema["properties"]
|
||||
|
||||
def test_current_access_token_exported_from_all(self):
|
||||
"""Test that CurrentAccessToken is exported from __all__."""
|
||||
from fastmcp.server import dependencies
|
||||
|
||||
assert "CurrentAccessToken" in dependencies.__all__
|
||||
|
||||
def test_token_claim_exported_from_all(self):
|
||||
"""Test that TokenClaim is exported from __all__."""
|
||||
from fastmcp.server import dependencies
|
||||
|
||||
assert "TokenClaim" in dependencies.__all__
|
||||
|
|
|
|||
65
uv.lock
generated
65
uv.lock
generated
|
|
@ -97,6 +97,35 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/54/51/321e821856452f7386c4e9df866f196720b1ad0c5ea1623ea7399969ae3b/authlib-1.6.6-py2.py3-none-any.whl", hash = "sha256:7d9e9bc535c13974313a87f53e8430eb6ea3d1cf6ae4f6efcd793f2e949143fd", size = 244005, upload-time = "2025-12-12T08:01:40.209Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "azure-core"
|
||||
version = "1.38.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "requests" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/dc/1b/e503e08e755ea94e7d3419c9242315f888fc664211c90d032e40479022bf/azure_core-1.38.0.tar.gz", hash = "sha256:8194d2682245a3e4e3151a667c686464c3786fed7918b394d035bdcd61bb5993", size = 363033, upload-time = "2026-01-12T17:03:05.535Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/d8/b8fcba9464f02b121f39de2db2bf57f0b216fe11d014513d666e8634380d/azure_core-1.38.0-py3-none-any.whl", hash = "sha256:ab0c9b2cd71fecb1842d52c965c95285d3cfb38902f6766e4a471f1cd8905335", size = 217825, upload-time = "2026-01-12T17:03:07.291Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "azure-identity"
|
||||
version = "1.25.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "azure-core" },
|
||||
{ name = "cryptography" },
|
||||
{ name = "msal" },
|
||||
{ name = "msal-extensions" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/8d/1a6c41c28a37eab26dc85ab6c86992c700cd3f4a597d9ed174b0e9c69489/azure_identity-1.25.1.tar.gz", hash = "sha256:87ca8328883de6036443e1c37b40e8dc8fb74898240f61071e09d2e369361456", size = 279826, upload-time = "2025-10-06T20:30:02.194Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/83/7b/5652771e24fff12da9dde4c20ecf4682e606b104f26419d139758cc935a6/azure_identity-1.25.1-py3-none-any.whl", hash = "sha256:e9edd720af03dff020223cd269fa3a61e8f345ea75443858273bcb44844ab651", size = 191317, upload-time = "2025-10-06T20:30:04.251Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "backports-asyncio-runner"
|
||||
version = "1.2.0"
|
||||
|
|
@ -707,6 +736,9 @@ dependencies = [
|
|||
anthropic = [
|
||||
{ name = "anthropic" },
|
||||
]
|
||||
azure = [
|
||||
{ name = "azure-identity" },
|
||||
]
|
||||
openai = [
|
||||
{ name = "openai" },
|
||||
]
|
||||
|
|
@ -718,7 +750,7 @@ tasks = [
|
|||
dev = [
|
||||
{ name = "dirty-equals" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "fastmcp", extra = ["anthropic", "openai", "tasks"] },
|
||||
{ name = "fastmcp", extra = ["anthropic", "azure", "openai", "tasks"] },
|
||||
{ name = "inline-snapshot", extra = ["dirty-equals"] },
|
||||
{ name = "ipython", version = "8.38.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||
{ name = "ipython", version = "9.9.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
||||
|
|
@ -748,6 +780,7 @@ dev = [
|
|||
requires-dist = [
|
||||
{ name = "anthropic", marker = "extra == 'anthropic'", specifier = ">=0.40.0" },
|
||||
{ name = "authlib", specifier = ">=1.6.5" },
|
||||
{ name = "azure-identity", marker = "extra == 'azure'", specifier = ">=1.16.0" },
|
||||
{ name = "cyclopts", specifier = ">=4.0.0" },
|
||||
{ name = "exceptiongroup", specifier = ">=1.2.2" },
|
||||
{ name = "httpx", specifier = ">=0.28.1,<1.0" },
|
||||
|
|
@ -770,13 +803,13 @@ requires-dist = [
|
|||
{ name = "watchfiles", specifier = ">=1.0.0" },
|
||||
{ name = "websockets", specifier = ">=15.0.1" },
|
||||
]
|
||||
provides-extras = ["anthropic", "openai", "tasks"]
|
||||
provides-extras = ["anthropic", "azure", "openai", "tasks"]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "dirty-equals", specifier = ">=0.9.0" },
|
||||
{ name = "fastapi", specifier = ">=0.115.12" },
|
||||
{ name = "fastmcp", extras = ["anthropic", "openai", "tasks"] },
|
||||
{ name = "fastmcp", extras = ["anthropic", "azure", "openai", "tasks"] },
|
||||
{ name = "inline-snapshot", extras = ["dirty-equals"], specifier = ">=0.27.2" },
|
||||
{ name = "ipython", specifier = ">=8.12.3" },
|
||||
{ name = "loq", specifier = ">=0.1.0a3" },
|
||||
|
|
@ -1413,6 +1446,32 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", size = 69667, upload-time = "2025-09-02T15:23:09.635Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "msal"
|
||||
version = "1.34.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cryptography" },
|
||||
{ name = "pyjwt", extra = ["crypto"] },
|
||||
{ name = "requests" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cf/0e/c857c46d653e104019a84f22d4494f2119b4fe9f896c92b4b864b3b045cc/msal-1.34.0.tar.gz", hash = "sha256:76ba83b716ea5a6d75b0279c0ac353a0e05b820ca1f6682c0eb7f45190c43c2f", size = 153961, upload-time = "2025-09-22T23:05:48.989Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/dc/18d48843499e278538890dc709e9ee3dea8375f8be8e82682851df1b48b5/msal-1.34.0-py3-none-any.whl", hash = "sha256:f669b1644e4950115da7a176441b0e13ec2975c29528d8b9e81316023676d6e1", size = 116987, upload-time = "2025-09-22T23:05:47.294Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "msal-extensions"
|
||||
version = "1.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "msal" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/01/99/5d239b6156eddf761a636bded1118414d161bd6b7b37a9335549ed159396/msal_extensions-1.3.1.tar.gz", hash = "sha256:c5b0fd10f65ef62b5f1d62f4251d51cbcaf003fcedae8c91b040a488614be1a4", size = 23315, upload-time = "2025-03-14T23:51:03.902Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/75/bd9b7bb966668920f06b200e84454c8f3566b102183bc55c5473d96cb2b9/msal_extensions-1.3.1-py3-none-any.whl", hash = "sha256:96d3de4d034504e969ac5e85bae8106c8373b5c6568e4c8fa7af2eca9dbe6bca", size = 20583, upload-time = "2025-03-14T23:51:03.016Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openai"
|
||||
version = "2.16.0"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue