mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 15:19:10 +02:00
WIP auth provider plugins checkpoint
This commit is contained in:
parent
428427220f
commit
835ba07bcd
137 changed files with 6031 additions and 5338 deletions
|
|
@ -478,7 +478,7 @@ When mounting an OAuth-protected server under a path prefix, declare your URLs u
|
|||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.github import GitHubProvider
|
||||
from fastmcp.server.plugins.auth.github.provider import GitHubProvider
|
||||
from starlette.applications import Starlette
|
||||
from starlette.routing import Mount
|
||||
|
||||
|
|
@ -540,7 +540,7 @@ Here's a complete working example showing all the pieces together:
|
|||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.github import GitHubProvider
|
||||
from fastmcp.server.plugins.auth.github.provider import GitHubProvider
|
||||
from starlette.applications import Starlette
|
||||
from starlette.routing import Mount
|
||||
import uvicorn
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ async def my_tool(
|
|||
For Azure/Entra, the new `fastmcp[azure]` extra adds `EntraOBOToken`, which handles the On-Behalf-Of token exchange declaratively:
|
||||
|
||||
```python
|
||||
from fastmcp.server.auth.providers.azure import EntraOBOToken
|
||||
from fastmcp.server.plugins.auth.azure.provider import EntraOBOToken
|
||||
|
||||
@mcp.tool()
|
||||
async def get_emails(
|
||||
|
|
|
|||
|
|
@ -252,7 +252,7 @@ auth = GitHubProvider()
|
|||
|
||||
# After (v3) — pass values explicitly
|
||||
import os
|
||||
from fastmcp.server.auth.providers.github import GitHubProvider
|
||||
from fastmcp.server.plugins.auth.github.provider import GitHubProvider
|
||||
|
||||
auth = GitHubProvider(
|
||||
client_id=os.environ["GITHUB_CLIENT_ID"],
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ Create an Application in your Auth0 settings to get the credentials needed for a
|
|||
</Warning>
|
||||
|
||||
<Tip>
|
||||
If you want to use a custom callback path (e.g., `/auth/auth0/callback`), make sure to set the same path in both your Auth0 Application settings and the `redirect_path` parameter when configuring the Auth0Provider.
|
||||
If you want to use a custom callback path (e.g., `/auth/auth0/callback`), make sure to set the same path in both your Auth0 Application settings and the `redirect_path` parameter when configuring the Auth0 plugin.
|
||||
</Tip>
|
||||
</Step>
|
||||
|
||||
|
|
@ -76,23 +76,25 @@ Create an Application in your Auth0 settings to get the credentials needed for a
|
|||
|
||||
### Step 2: FastMCP Configuration
|
||||
|
||||
Create your FastMCP server using the `Auth0Provider`.
|
||||
Create your FastMCP server using the `Auth0Auth` plugin.
|
||||
|
||||
```python server.py
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.auth0 import Auth0Provider
|
||||
from fastmcp.server.plugins.auth.auth0 import Auth0Auth
|
||||
|
||||
# The Auth0Provider utilizes Auth0 OIDC configuration
|
||||
auth_provider = Auth0Provider(
|
||||
config_url="https://.../.well-known/openid-configuration", # Your Auth0 configuration URL
|
||||
client_id="tv2ObNgaZAWWhhycr7Bz1LU2mxlnsmsB", # Your Auth0 application Client ID
|
||||
client_secret="vPYqbjemq...", # Your Auth0 application Client Secret
|
||||
audience="https://...", # Your Auth0 API audience
|
||||
base_url="http://localhost:8000", # Must match your application configuration
|
||||
# redirect_path="/auth/callback" # Default value, customize if needed
|
||||
# The Auth0 plugin utilizes Auth0 OIDC configuration
|
||||
auth_plugin = Auth0Auth(
|
||||
Auth0Auth.Config(
|
||||
config_url="https://.../.well-known/openid-configuration", # Your Auth0 configuration URL
|
||||
client_id="tv2ObNgaZAWWhhycr7Bz1LU2mxlnsmsB", # Your Auth0 application Client ID
|
||||
client_secret="vPYqbjemq...", # Your Auth0 application Client Secret
|
||||
audience="https://...", # Your Auth0 API audience
|
||||
base_url="http://localhost:8000", # Must match your application configuration
|
||||
# redirect_path="/auth/callback" # Default value, customize if needed
|
||||
)
|
||||
)
|
||||
|
||||
mcp = FastMCP(name="Auth0 Secured App", auth=auth_provider)
|
||||
mcp = FastMCP(name="Auth0 Secured App", plugins=[auth_plugin])
|
||||
|
||||
# Add a protected tool to test authentication
|
||||
@mcp.tool
|
||||
|
|
@ -157,21 +159,21 @@ For production deployments with persistent token management across server restar
|
|||
```python server.py
|
||||
import os
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.auth0 import Auth0Provider
|
||||
from fastmcp.server.plugins.auth.auth0 import Auth0Auth
|
||||
from key_value.aio.stores.redis import RedisStore
|
||||
from key_value.aio.wrappers.encryption import FernetEncryptionWrapper
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
# Production setup with encrypted persistent token storage
|
||||
auth_provider = Auth0Provider(
|
||||
config_url="https://.../.well-known/openid-configuration",
|
||||
client_id="tv2ObNgaZAWWhhycr7Bz1LU2mxlnsmsB",
|
||||
client_secret="vPYqbjemq...",
|
||||
audience="https://...",
|
||||
base_url="https://your-production-domain.com",
|
||||
|
||||
# Production token management
|
||||
jwt_signing_key=os.environ["JWT_SIGNING_KEY"],
|
||||
auth_plugin = Auth0Auth(
|
||||
Auth0Auth.Config(
|
||||
config_url="https://.../.well-known/openid-configuration",
|
||||
client_id="tv2ObNgaZAWWhhycr7Bz1LU2mxlnsmsB",
|
||||
client_secret="vPYqbjemq...",
|
||||
audience="https://...",
|
||||
base_url="https://your-production-domain.com",
|
||||
jwt_signing_key=os.environ["JWT_SIGNING_KEY"],
|
||||
),
|
||||
client_storage=FernetEncryptionWrapper(
|
||||
key_value=RedisStore(
|
||||
host=os.environ["REDIS_HOST"],
|
||||
|
|
@ -181,7 +183,7 @@ auth_provider = Auth0Provider(
|
|||
)
|
||||
)
|
||||
|
||||
mcp = FastMCP(name="Production Auth0 App", auth=auth_provider)
|
||||
mcp = FastMCP(name="Production Auth0 App", plugins=[auth_plugin])
|
||||
```
|
||||
|
||||
<Note>
|
||||
|
|
|
|||
|
|
@ -44,20 +44,22 @@ In the WorkOS Dashboard, go to **Connect → Configuration** and configure:
|
|||
|
||||
### Step 2: FastMCP Configuration
|
||||
|
||||
Create your FastMCP server file and use the `AuthKitProvider` to handle all the OAuth integration automatically:
|
||||
Create your FastMCP server file and use the `AuthKitAuth` plugin to handle the OAuth integration automatically:
|
||||
|
||||
```python server.py
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.workos import AuthKitProvider
|
||||
from fastmcp.server.plugins.auth.authkit import AuthKitAuth
|
||||
|
||||
# AuthKitProvider automatically discovers WorkOS endpoints, configures JWT
|
||||
# AuthKitAuth automatically discovers WorkOS endpoints, configures JWT
|
||||
# validation, and binds the token audience to this server's resource URL.
|
||||
auth_provider = AuthKitProvider(
|
||||
authkit_domain="https://your-project-12345.authkit.app",
|
||||
base_url="http://127.0.0.1:8000", # Use your actual server URL
|
||||
auth_plugin = AuthKitAuth(
|
||||
AuthKitAuth.Config(
|
||||
authkit_domain="https://your-project-12345.authkit.app",
|
||||
base_url="http://127.0.0.1:8000", # Use your actual server URL
|
||||
)
|
||||
)
|
||||
|
||||
mcp = FastMCP(name="AuthKit Secured App", auth=auth_provider)
|
||||
mcp = FastMCP(name="AuthKit Secured App", plugins=[auth_plugin])
|
||||
```
|
||||
|
||||
When the server starts, it logs the resource URL it is validating against. Paste that URL into your Dashboard's **MCP resource indicators** list.
|
||||
|
|
@ -94,13 +96,15 @@ For production deployments, load sensitive configuration from environment variab
|
|||
```python server.py
|
||||
import os
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.workos import AuthKitProvider
|
||||
from fastmcp.server.plugins.auth.authkit import AuthKitAuth
|
||||
|
||||
# Load configuration from environment variables
|
||||
auth = AuthKitProvider(
|
||||
authkit_domain=os.environ.get("AUTHKIT_DOMAIN"),
|
||||
base_url=os.environ.get("BASE_URL", "https://your-server.com"),
|
||||
auth_plugin = AuthKitAuth(
|
||||
AuthKitAuth.Config(
|
||||
authkit_domain=os.environ.get("AUTHKIT_DOMAIN"),
|
||||
base_url=os.environ.get("BASE_URL", "https://your-server.com"),
|
||||
)
|
||||
)
|
||||
|
||||
mcp = FastMCP(name="AuthKit Secured App", auth=auth)
|
||||
mcp = FastMCP(name="AuthKit Secured App", plugins=[auth_plugin])
|
||||
```
|
||||
|
|
|
|||
|
|
@ -116,24 +116,26 @@ Set up AWS Cognito user pool with an app client to get the credentials needed fo
|
|||
|
||||
### Step 2: FastMCP Configuration
|
||||
|
||||
Create your FastMCP server using the `AWSCognitoProvider`, which handles AWS Cognito's JWT tokens and user claims automatically:
|
||||
Create your FastMCP server using the `AWSCognitoAuth` plugin, which handles AWS Cognito's JWT tokens and user claims automatically:
|
||||
|
||||
```python server.py
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.aws import AWSCognitoProvider
|
||||
from fastmcp.server.plugins.auth.aws import AWSCognitoAuth
|
||||
from fastmcp.server.dependencies import get_access_token
|
||||
|
||||
# The AWSCognitoProvider handles JWT validation and user claims
|
||||
auth_provider = AWSCognitoProvider(
|
||||
user_pool_id="eu-central-1_XXXXXXXXX", # Your AWS Cognito user pool ID
|
||||
aws_region="eu-central-1", # AWS region (defaults to eu-central-1)
|
||||
client_id="your-app-client-id", # Your app client ID
|
||||
client_secret="your-app-client-secret", # Your app client Secret
|
||||
base_url="http://localhost:8000", # Must match your callback URL
|
||||
# redirect_path="/auth/callback" # Default value, customize if needed
|
||||
# The AWSCognitoAuth plugin handles JWT validation and user claims
|
||||
auth_plugin = AWSCognitoAuth(
|
||||
AWSCognitoAuth.Config(
|
||||
user_pool_id="eu-central-1_XXXXXXXXX", # Your AWS Cognito user pool ID
|
||||
aws_region="eu-central-1", # AWS region (defaults to eu-central-1)
|
||||
client_id="your-app-client-id", # Your app client ID
|
||||
client_secret="your-app-client-secret", # Your app client Secret
|
||||
base_url="http://localhost:8000", # Must match your callback URL
|
||||
# redirect_path="/auth/callback" # Default value, customize if needed
|
||||
)
|
||||
)
|
||||
|
||||
mcp = FastMCP(name="AWS Cognito Secured App", auth=auth_provider)
|
||||
mcp = FastMCP(name="AWS Cognito Secured App", plugins=[auth_plugin])
|
||||
|
||||
# Add a protected tool to test authentication
|
||||
@mcp.tool
|
||||
|
|
@ -204,21 +206,21 @@ For production deployments with persistent token management across server restar
|
|||
```python server.py
|
||||
import os
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.aws import AWSCognitoProvider
|
||||
from fastmcp.server.plugins.auth.aws import AWSCognitoAuth
|
||||
from key_value.aio.stores.redis import RedisStore
|
||||
from key_value.aio.wrappers.encryption import FernetEncryptionWrapper
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
# Production setup with encrypted persistent token storage
|
||||
auth_provider = AWSCognitoProvider(
|
||||
user_pool_id="eu-central-1_XXXXXXXXX",
|
||||
aws_region="eu-central-1",
|
||||
client_id="your-app-client-id",
|
||||
client_secret="your-app-client-secret",
|
||||
base_url="https://your-production-domain.com",
|
||||
|
||||
# Production token management
|
||||
jwt_signing_key=os.environ["JWT_SIGNING_KEY"],
|
||||
auth_plugin = AWSCognitoAuth(
|
||||
AWSCognitoAuth.Config(
|
||||
user_pool_id="eu-central-1_XXXXXXXXX",
|
||||
aws_region="eu-central-1",
|
||||
client_id="your-app-client-id",
|
||||
client_secret="your-app-client-secret",
|
||||
base_url="https://your-production-domain.com",
|
||||
jwt_signing_key=os.environ["JWT_SIGNING_KEY"],
|
||||
),
|
||||
client_storage=FernetEncryptionWrapper(
|
||||
key_value=RedisStore(
|
||||
host=os.environ["REDIS_HOST"],
|
||||
|
|
@ -228,7 +230,7 @@ auth_provider = AWSCognitoProvider(
|
|||
)
|
||||
)
|
||||
|
||||
mcp = FastMCP(name="Production AWS Cognito App", auth=auth_provider)
|
||||
mcp = FastMCP(name="Production AWS Cognito App", plugins=[auth_plugin])
|
||||
```
|
||||
|
||||
<Note>
|
||||
|
|
@ -275,4 +277,4 @@ Perfect for enterprise environments with:
|
|||
- **Multi-Factor Authentication (MFA)**: Leverage AWS Cognito's built-in MFA
|
||||
- **User Groups**: Role-based access control through AWS Cognito groups
|
||||
- **Custom Attributes**: Access custom user attributes defined in your AWS Cognito user pool
|
||||
- **Compliance**: Meet enterprise security and compliance requirements
|
||||
- **Compliance**: Meet enterprise security and compliance requirements
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ Create an App registration in Azure Portal to get the credentials needed for aut
|
|||
</Warning>
|
||||
|
||||
<Tip>
|
||||
If you want to use a custom callback path (e.g., `/auth/azure/callback`), make sure to set the same path in both your Azure App registration and the `redirect_path` parameter when configuring the AzureProvider.
|
||||
If you want to use a custom callback path (e.g., `/auth/azure/callback`), make sure to set the same path in both your Azure App registration and the `redirect_path` parameter when configuring the Azure plugin.
|
||||
</Tip>
|
||||
|
||||
- **Expose an API**: Configure your Application ID URI and define scopes
|
||||
|
|
@ -74,7 +74,7 @@ Create an App registration in Azure Portal to get the credentials needed for aut
|
|||
</Warning>
|
||||
|
||||
<Note>
|
||||
In FastMCP's `AzureProvider`, set `identifier_uri` to your Application ID URI (optional; defaults to `api://{client_id}`) and set `required_scopes` to the unprefixed scope names (e.g., `read`, `write`). During authorization, FastMCP automatically prefixes scopes with your `identifier_uri`.
|
||||
In FastMCP's Azure plugin, set `identifier_uri` to your Application ID URI (optional; defaults to `api://{client_id}`) and set `required_scopes` to the unprefixed scope names (e.g., `read`, `write`). During authorization, FastMCP automatically prefixes scopes with your `identifier_uri`.
|
||||
</Note>
|
||||
|
||||
|
||||
|
|
@ -109,28 +109,30 @@ Create an App registration in Azure Portal to get the credentials needed for aut
|
|||
|
||||
### Step 2: FastMCP Configuration
|
||||
|
||||
Create your FastMCP server using the `AzureProvider`, which handles Azure's OAuth flow automatically:
|
||||
Create your FastMCP server using the `AzureAuth` plugin, which handles Azure's OAuth flow automatically:
|
||||
|
||||
```python server.py
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.azure import AzureProvider
|
||||
from fastmcp.server.plugins.auth.azure import AzureAuth
|
||||
|
||||
# The AzureProvider handles Azure's token format and validation
|
||||
auth_provider = AzureProvider(
|
||||
client_id="835f09b6-0f0f-40cc-85cb-f32c5829a149", # Your Azure App Client ID
|
||||
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"], # 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
|
||||
# additional_authorize_scopes=["User.Read", "openid", "email"],
|
||||
# redirect_path="/auth/callback" # Default value, customize if needed
|
||||
# base_authority="login.microsoftonline.us" # For Azure Government (default: login.microsoftonline.com)
|
||||
# The AzureAuth plugin handles Azure's token format and validation
|
||||
auth_plugin = AzureAuth(
|
||||
AzureAuth.Config(
|
||||
client_id="835f09b6-0f0f-40cc-85cb-f32c5829a149", # Your Azure App Client ID
|
||||
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"], # 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
|
||||
# additional_authorize_scopes=["User.Read", "openid", "email"],
|
||||
# redirect_path="/auth/callback" # Default value, customize if needed
|
||||
# base_authority="login.microsoftonline.us" # For Azure Government (default: login.microsoftonline.com)
|
||||
)
|
||||
)
|
||||
|
||||
mcp = FastMCP(name="Azure Secured App", auth=auth_provider)
|
||||
mcp = FastMCP(name="Azure Secured App", plugins=[auth_plugin])
|
||||
|
||||
# Add a protected tool to test authentication
|
||||
@mcp.tool
|
||||
|
|
@ -139,7 +141,7 @@ async def get_user_info() -> dict:
|
|||
from fastmcp.server.dependencies import get_access_token
|
||||
|
||||
token = get_access_token()
|
||||
# The AzureProvider stores user data in token claims
|
||||
# The Azure plugin stores user data in token claims
|
||||
return {
|
||||
"azure_id": token.claims.get("sub"),
|
||||
"email": token.claims.get("email"),
|
||||
|
|
@ -250,21 +252,21 @@ For production deployments with persistent token management across server restar
|
|||
```python server.py
|
||||
import os
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.azure import AzureProvider
|
||||
from fastmcp.server.plugins.auth.azure import AzureAuth
|
||||
from key_value.aio.stores.redis import RedisStore
|
||||
from key_value.aio.wrappers.encryption import FernetEncryptionWrapper
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
# Production setup with encrypted persistent token storage
|
||||
auth_provider = AzureProvider(
|
||||
client_id="835f09b6-0f0f-40cc-85cb-f32c5829a149",
|
||||
client_secret="your-client-secret",
|
||||
tenant_id="08541b6e-646d-43de-a0eb-834e6713d6d5",
|
||||
base_url="https://your-production-domain.com",
|
||||
required_scopes=["your-scope"],
|
||||
|
||||
# Production token management
|
||||
jwt_signing_key=os.environ["JWT_SIGNING_KEY"],
|
||||
auth_plugin = AzureAuth(
|
||||
AzureAuth.Config(
|
||||
client_id="835f09b6-0f0f-40cc-85cb-f32c5829a149",
|
||||
client_secret="your-client-secret",
|
||||
tenant_id="08541b6e-646d-43de-a0eb-834e6713d6d5",
|
||||
base_url="https://your-production-domain.com",
|
||||
required_scopes=["your-scope"],
|
||||
jwt_signing_key=os.environ["JWT_SIGNING_KEY"],
|
||||
),
|
||||
client_storage=FernetEncryptionWrapper(
|
||||
key_value=RedisStore(
|
||||
host=os.environ["REDIS_HOST"],
|
||||
|
|
@ -274,7 +276,7 @@ auth_provider = AzureProvider(
|
|||
)
|
||||
)
|
||||
|
||||
mcp = FastMCP(name="Production Azure App", auth=auth_provider)
|
||||
mcp = FastMCP(name="Production Azure App", plugins=[auth_plugin])
|
||||
```
|
||||
|
||||
<Note>
|
||||
|
|
@ -287,7 +289,7 @@ For complete details on these parameters, see the [OAuth Proxy documentation](/s
|
|||
|
||||
<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`.
|
||||
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 Azure auth plugin.
|
||||
|
||||
This pattern is ideal when:
|
||||
- Your infrastructure handles authentication (e.g., Managed Identity)
|
||||
|
|
@ -297,7 +299,7 @@ This pattern is ideal when:
|
|||
```python server.py
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth import RemoteAuthProvider
|
||||
from fastmcp.server.auth.providers.azure import AzureJWTVerifier
|
||||
from fastmcp.server.plugins.auth.azure.provider import AzureJWTVerifier
|
||||
from pydantic import AnyHttpUrl
|
||||
|
||||
tenant_id = "your-tenant-id"
|
||||
|
|
@ -371,29 +373,31 @@ OBO requires additional configuration in your Azure App registration beyond basi
|
|||
</Step>
|
||||
</Steps>
|
||||
|
||||
### Configure AzureProvider for OBO
|
||||
### Configure AzureAuth 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
|
||||
from fastmcp.server.plugins.auth.azure import AzureAuth
|
||||
|
||||
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
|
||||
],
|
||||
auth_plugin = AzureAuth(
|
||||
AzureAuth.Config(
|
||||
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)
|
||||
mcp = FastMCP(name="Graph-Enabled Server", plugins=[auth_plugin])
|
||||
```
|
||||
|
||||
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.
|
||||
|
|
@ -408,22 +412,25 @@ The `EntraOBOToken` dependency handles the complete OBO flow automatically. Decl
|
|||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.azure import AzureProvider, EntraOBOToken
|
||||
from fastmcp.server.plugins.auth.azure import AzureAuth
|
||||
from fastmcp.server.plugins.auth.azure.provider import 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",
|
||||
],
|
||||
auth_plugin = AzureAuth(
|
||||
AzureAuth.Config(
|
||||
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 = FastMCP(name="Email Reader", plugins=[auth_plugin])
|
||||
|
||||
@mcp.tool
|
||||
async def get_recent_emails(
|
||||
|
|
|
|||
|
|
@ -54,21 +54,23 @@ SERVER_URL=http://localhost:3000 # Your server's base URL
|
|||
|
||||
### Step 3: FastMCP Configuration
|
||||
|
||||
Create your FastMCP server file and use the DescopeProvider to handle all the OAuth integration automatically:
|
||||
Create your FastMCP server file and use the `DescopeAuth` plugin to handle the OAuth integration automatically:
|
||||
|
||||
```python server.py
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.descope import DescopeProvider
|
||||
from fastmcp.server.plugins.auth.descope import DescopeAuth
|
||||
|
||||
# The DescopeProvider automatically discovers Descope endpoints
|
||||
# The DescopeAuth plugin automatically discovers Descope endpoints
|
||||
# and configures JWT token validation
|
||||
auth_provider = DescopeProvider(
|
||||
config_url="https://.../.well-known/openid-configuration", # Your MCP Server .well-known URL
|
||||
base_url=SERVER_URL, # Your server's public URL
|
||||
auth_plugin = DescopeAuth(
|
||||
DescopeAuth.Config(
|
||||
config_url="https://.../.well-known/openid-configuration", # Your MCP Server .well-known URL
|
||||
base_url=SERVER_URL, # Your server's public URL
|
||||
)
|
||||
)
|
||||
|
||||
# Create FastMCP server with auth
|
||||
mcp = FastMCP(name="My Descope Protected Server", auth=auth_provider)
|
||||
# Create FastMCP server with auth plugin
|
||||
mcp = FastMCP(name="My Descope Protected Server", plugins=[auth_plugin])
|
||||
|
||||
```
|
||||
|
||||
|
|
@ -101,13 +103,15 @@ For production deployments, load configuration from environment variables:
|
|||
```python server.py
|
||||
import os
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.descope import DescopeProvider
|
||||
from fastmcp.server.plugins.auth.descope import DescopeAuth
|
||||
|
||||
# Load configuration from environment variables
|
||||
auth = DescopeProvider(
|
||||
config_url=os.environ.get("DESCOPE_CONFIG_URL"),
|
||||
base_url=os.environ.get("BASE_URL", "https://your-server.com")
|
||||
auth_plugin = DescopeAuth(
|
||||
DescopeAuth.Config(
|
||||
config_url=os.environ.get("DESCOPE_CONFIG_URL"),
|
||||
base_url=os.environ.get("BASE_URL", "https://your-server.com"),
|
||||
)
|
||||
)
|
||||
|
||||
mcp = FastMCP(name="My Descope Protected Server", auth=auth)
|
||||
mcp = FastMCP(name="My Descope Protected Server", plugins=[auth_plugin])
|
||||
```
|
||||
|
|
|
|||
|
|
@ -56,19 +56,21 @@ Create an application in the Discord Developer Portal to get the credentials nee
|
|||
|
||||
### Step 2: FastMCP Configuration
|
||||
|
||||
Create your FastMCP server using the `DiscordProvider`, which handles Discord's OAuth flow automatically:
|
||||
Create your FastMCP server using the `DiscordAuth` plugin, which handles Discord's OAuth flow automatically:
|
||||
|
||||
```python server.py
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.discord import DiscordProvider
|
||||
from fastmcp.server.plugins.auth.discord import DiscordAuth
|
||||
|
||||
auth_provider = DiscordProvider(
|
||||
client_id="12345", # Your Discord Application Client ID
|
||||
client_secret="your-client-secret", # Your Discord OAuth Client Secret
|
||||
base_url="http://localhost:8000", # Must match your OAuth configuration
|
||||
auth_plugin = DiscordAuth(
|
||||
DiscordAuth.Config(
|
||||
client_id="12345", # Your Discord Application Client ID
|
||||
client_secret="your-client-secret", # Your Discord OAuth Client Secret
|
||||
base_url="http://localhost:8000", # Must match your OAuth configuration
|
||||
)
|
||||
)
|
||||
|
||||
mcp = FastMCP(name="Discord Secured App", auth=auth_provider)
|
||||
mcp = FastMCP(name="Discord Secured App", plugins=[auth_plugin])
|
||||
|
||||
@mcp.tool
|
||||
async def get_user_info() -> dict:
|
||||
|
|
@ -138,11 +140,13 @@ Discord OAuth supports several scopes for accessing different types of user data
|
|||
To request additional scopes:
|
||||
|
||||
```python
|
||||
auth_provider = DiscordProvider(
|
||||
client_id="...",
|
||||
client_secret="...",
|
||||
base_url="http://localhost:8000",
|
||||
required_scopes=["identify", "email"],
|
||||
auth_plugin = DiscordAuth(
|
||||
DiscordAuth.Config(
|
||||
client_id="...",
|
||||
client_secret="...",
|
||||
base_url="http://localhost:8000",
|
||||
required_scopes=["identify", "email"],
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
|
|
@ -153,17 +157,18 @@ For production deployments with persistent token management across server restar
|
|||
```python server.py
|
||||
import os
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.discord import DiscordProvider
|
||||
from fastmcp.server.plugins.auth.discord import DiscordAuth
|
||||
from key_value.aio.stores.redis import RedisStore
|
||||
from key_value.aio.wrappers.encryption import FernetEncryptionWrapper
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
auth_provider = DiscordProvider(
|
||||
client_id="12345",
|
||||
client_secret=os.environ["DISCORD_CLIENT_SECRET"],
|
||||
base_url="https://your-production-domain.com",
|
||||
|
||||
jwt_signing_key=os.environ["JWT_SIGNING_KEY"],
|
||||
auth_plugin = DiscordAuth(
|
||||
DiscordAuth.Config(
|
||||
client_id="12345",
|
||||
client_secret=os.environ["DISCORD_CLIENT_SECRET"],
|
||||
base_url="https://your-production-domain.com",
|
||||
jwt_signing_key=os.environ["JWT_SIGNING_KEY"],
|
||||
),
|
||||
client_storage=FernetEncryptionWrapper(
|
||||
key_value=RedisStore(
|
||||
host=os.environ["REDIS_HOST"],
|
||||
|
|
@ -173,7 +178,7 @@ auth_provider = DiscordProvider(
|
|||
)
|
||||
)
|
||||
|
||||
mcp = FastMCP(name="Production Discord App", auth=auth_provider)
|
||||
mcp = FastMCP(name="Production Discord App", plugins=[auth_plugin])
|
||||
```
|
||||
|
||||
<Note>
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ Create an OAuth App in your GitHub settings to get the credentials needed for au
|
|||
</Warning>
|
||||
|
||||
<Tip>
|
||||
If you want to use a custom callback path (e.g., `/auth/github/callback`), make sure to set the same path in both your GitHub OAuth App settings and the `redirect_path` parameter when configuring the GitHubProvider.
|
||||
If you want to use a custom callback path (e.g., `/auth/github/callback`), make sure to set the same path in both your GitHub OAuth App settings and the `redirect_path` parameter when configuring the GitHub plugin.
|
||||
</Tip>
|
||||
</Step>
|
||||
|
||||
|
|
@ -60,21 +60,23 @@ Create an OAuth App in your GitHub settings to get the credentials needed for au
|
|||
|
||||
### Step 2: FastMCP Configuration
|
||||
|
||||
Create your FastMCP server using the `GitHubProvider`, which handles GitHub's OAuth quirks automatically:
|
||||
Create your FastMCP server using the `GitHubAuth` plugin, which handles GitHub's OAuth quirks automatically:
|
||||
|
||||
```python server.py
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.github import GitHubProvider
|
||||
from fastmcp.server.plugins.auth.github import GitHubAuth
|
||||
|
||||
# The GitHubProvider handles GitHub's token format and validation
|
||||
auth_provider = GitHubProvider(
|
||||
client_id="Ov23liAbcDefGhiJkLmN", # Your GitHub OAuth App Client ID
|
||||
client_secret="github_pat_...", # Your GitHub OAuth App Client Secret
|
||||
base_url="http://localhost:8000", # Must match your OAuth App configuration
|
||||
# redirect_path="/auth/callback" # Default value, customize if needed
|
||||
# The GitHubAuth plugin handles GitHub's token format and validation
|
||||
auth_plugin = GitHubAuth(
|
||||
GitHubAuth.Config(
|
||||
client_id="Ov23liAbcDefGhiJkLmN", # Your GitHub OAuth App Client ID
|
||||
client_secret="github_pat_...", # Your GitHub OAuth App Client Secret
|
||||
base_url="http://localhost:8000", # Must match your OAuth App configuration
|
||||
# redirect_path="/auth/callback" # Default value, customize if needed
|
||||
)
|
||||
)
|
||||
|
||||
mcp = FastMCP(name="GitHub Secured App", auth=auth_provider)
|
||||
mcp = FastMCP(name="GitHub Secured App", plugins=[auth_plugin])
|
||||
|
||||
# Add a protected tool to test authentication
|
||||
@mcp.tool
|
||||
|
|
@ -83,7 +85,7 @@ async def get_user_info() -> dict:
|
|||
from fastmcp.server.dependencies import get_access_token
|
||||
|
||||
token = get_access_token()
|
||||
# The GitHubProvider stores user data in token claims
|
||||
# The GitHub auth plugin stores user data in token claims
|
||||
return {
|
||||
"github_user": token.claims.get("login"),
|
||||
"name": token.claims.get("name"),
|
||||
|
|
@ -143,19 +145,19 @@ For production deployments with persistent token management across server restar
|
|||
```python server.py
|
||||
import os
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.github import GitHubProvider
|
||||
from fastmcp.server.plugins.auth.github import GitHubAuth
|
||||
from key_value.aio.stores.redis import RedisStore
|
||||
from key_value.aio.wrappers.encryption import FernetEncryptionWrapper
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
# Production setup with encrypted persistent token storage
|
||||
auth_provider = GitHubProvider(
|
||||
client_id="Ov23liAbcDefGhiJkLmN",
|
||||
client_secret="github_pat_...",
|
||||
base_url="https://your-production-domain.com",
|
||||
|
||||
# Production token management
|
||||
jwt_signing_key=os.environ["JWT_SIGNING_KEY"],
|
||||
auth_plugin = GitHubAuth(
|
||||
GitHubAuth.Config(
|
||||
client_id="Ov23liAbcDefGhiJkLmN",
|
||||
client_secret="github_pat_...",
|
||||
base_url="https://your-production-domain.com",
|
||||
jwt_signing_key=os.environ["JWT_SIGNING_KEY"],
|
||||
),
|
||||
client_storage=FernetEncryptionWrapper(
|
||||
key_value=RedisStore(
|
||||
host=os.environ["REDIS_HOST"],
|
||||
|
|
@ -165,7 +167,7 @@ auth_provider = GitHubProvider(
|
|||
)
|
||||
)
|
||||
|
||||
mcp = FastMCP(name="Production GitHub App", auth=auth_provider)
|
||||
mcp = FastMCP(name="Production GitHub App", plugins=[auth_plugin])
|
||||
```
|
||||
|
||||
<Note>
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ Create an OAuth 2.0 Client ID in your Google Cloud Console to get the credential
|
|||
</Warning>
|
||||
|
||||
<Tip>
|
||||
If you want to use a custom callback path (e.g., `/auth/google/callback`), make sure to set the same path in both your Google OAuth Client settings and the `redirect_path` parameter when configuring the GoogleProvider.
|
||||
If you want to use a custom callback path (e.g., `/auth/google/callback`), make sure to set the same path in both your Google OAuth Client settings and the `redirect_path` parameter when configuring the Google plugin.
|
||||
</Tip>
|
||||
</Step>
|
||||
|
||||
|
|
@ -65,25 +65,27 @@ Create an OAuth 2.0 Client ID in your Google Cloud Console to get the credential
|
|||
|
||||
### Step 2: FastMCP Configuration
|
||||
|
||||
Create your FastMCP server using the `GoogleProvider`, which handles Google's OAuth flow automatically:
|
||||
Create your FastMCP server using the `GoogleAuth` plugin, which handles Google's OAuth flow automatically:
|
||||
|
||||
```python server.py
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.google import GoogleProvider
|
||||
from fastmcp.server.plugins.auth.google import GoogleAuth
|
||||
|
||||
# The GoogleProvider handles Google's token format and validation
|
||||
auth_provider = GoogleProvider(
|
||||
client_id="123456789.apps.googleusercontent.com", # Your Google OAuth Client ID
|
||||
client_secret="GOCSPX-abc123...", # Your Google OAuth Client Secret
|
||||
base_url="http://localhost:8000", # Must match your OAuth configuration
|
||||
required_scopes=[ # Request user information
|
||||
"openid",
|
||||
"https://www.googleapis.com/auth/userinfo.email",
|
||||
],
|
||||
# redirect_path="/auth/callback" # Default value, customize if needed
|
||||
# The GoogleAuth plugin handles Google's token format and validation
|
||||
auth_plugin = GoogleAuth(
|
||||
GoogleAuth.Config(
|
||||
client_id="123456789.apps.googleusercontent.com", # Your Google OAuth Client ID
|
||||
client_secret="GOCSPX-abc123...", # Your Google OAuth Client Secret
|
||||
base_url="http://localhost:8000", # Must match your OAuth configuration
|
||||
required_scopes=[ # Request user information
|
||||
"openid",
|
||||
"https://www.googleapis.com/auth/userinfo.email",
|
||||
],
|
||||
# redirect_path="/auth/callback" # Default value, customize if needed
|
||||
)
|
||||
)
|
||||
|
||||
mcp = FastMCP(name="Google Secured App", auth=auth_provider)
|
||||
mcp = FastMCP(name="Google Secured App", plugins=[auth_plugin])
|
||||
|
||||
# Add a protected tool to test authentication
|
||||
@mcp.tool
|
||||
|
|
@ -92,7 +94,7 @@ async def get_user_info() -> dict:
|
|||
from fastmcp.server.dependencies import get_access_token
|
||||
|
||||
token = get_access_token()
|
||||
# The GoogleProvider stores user data in token claims
|
||||
# The Google auth plugin stores user data in token claims
|
||||
return {
|
||||
"google_id": token.claims.get("sub"),
|
||||
"email": token.claims.get("email"),
|
||||
|
|
@ -156,20 +158,20 @@ For production deployments with persistent token management across server restar
|
|||
```python server.py
|
||||
import os
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.google import GoogleProvider
|
||||
from fastmcp.server.plugins.auth.google import GoogleAuth
|
||||
from key_value.aio.stores.redis import RedisStore
|
||||
from key_value.aio.wrappers.encryption import FernetEncryptionWrapper
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
# Production setup with encrypted persistent token storage
|
||||
auth_provider = GoogleProvider(
|
||||
client_id="123456789.apps.googleusercontent.com",
|
||||
client_secret="GOCSPX-abc123...",
|
||||
base_url="https://your-production-domain.com",
|
||||
required_scopes=["openid", "https://www.googleapis.com/auth/userinfo.email"],
|
||||
|
||||
# Production token management
|
||||
jwt_signing_key=os.environ["JWT_SIGNING_KEY"],
|
||||
auth_plugin = GoogleAuth(
|
||||
GoogleAuth.Config(
|
||||
client_id="123456789.apps.googleusercontent.com",
|
||||
client_secret="GOCSPX-abc123...",
|
||||
base_url="https://your-production-domain.com",
|
||||
required_scopes=["openid", "https://www.googleapis.com/auth/userinfo.email"],
|
||||
jwt_signing_key=os.environ["JWT_SIGNING_KEY"],
|
||||
),
|
||||
client_storage=FernetEncryptionWrapper(
|
||||
key_value=RedisStore(
|
||||
host=os.environ["REDIS_HOST"],
|
||||
|
|
@ -179,11 +181,11 @@ auth_provider = GoogleProvider(
|
|||
)
|
||||
)
|
||||
|
||||
mcp = FastMCP(name="Production Google App", auth=auth_provider)
|
||||
mcp = FastMCP(name="Production Google App", plugins=[auth_plugin])
|
||||
```
|
||||
|
||||
<Note>
|
||||
Parameters (`jwt_signing_key` and `client_storage`) work together to ensure tokens and client registrations survive server restarts. **Wrap your storage in `FernetEncryptionWrapper` to encrypt sensitive OAuth tokens at rest** - without it, tokens are stored in plaintext. Store secrets in environment variables and use a persistent storage backend like Redis for distributed deployments.
|
||||
|
||||
For complete details on these parameters, see the [OAuth Proxy documentation](/servers/auth/oauth-proxy#configuration-parameters).
|
||||
</Note>
|
||||
</Note>
|
||||
|
|
|
|||
|
|
@ -27,22 +27,24 @@ Before you begin, you will need:
|
|||
|
||||
### FastMCP Configuration
|
||||
|
||||
Create your FastMCP server and use `KeycloakAuthProvider` to handle OAuth:
|
||||
Create your FastMCP server and use the `KeycloakAuth` plugin to handle OAuth:
|
||||
|
||||
```python server.py
|
||||
import os
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.keycloak import KeycloakAuthProvider
|
||||
from fastmcp.server.plugins.auth.keycloak import KeycloakAuth
|
||||
from fastmcp.server.dependencies import get_access_token
|
||||
|
||||
auth = KeycloakAuthProvider(
|
||||
realm_url=os.getenv("KEYCLOAK_REALM_URL") or "http://localhost:8080/realms/myrealm",
|
||||
base_url="http://localhost:8000",
|
||||
# audience="http://localhost:8000", # Recommended for production
|
||||
auth_plugin = KeycloakAuth(
|
||||
KeycloakAuth.Config(
|
||||
realm_url=os.getenv("KEYCLOAK_REALM_URL") or "http://localhost:8080/realms/myrealm",
|
||||
base_url="http://localhost:8000",
|
||||
# audience="http://localhost:8000", # Recommended for production
|
||||
)
|
||||
)
|
||||
|
||||
mcp = FastMCP("Keycloak Example Server", auth=auth)
|
||||
mcp = FastMCP("Keycloak Example Server", plugins=[auth_plugin])
|
||||
|
||||
|
||||
@mcp.tool
|
||||
|
|
@ -124,7 +126,7 @@ async def admin_only_tool() -> str:
|
|||
|
||||
```python
|
||||
from fastmcp.server.auth.providers.jwt import JWTVerifier
|
||||
from fastmcp.server.auth.providers.keycloak import KeycloakAuthProvider
|
||||
from fastmcp.server.plugins.auth.keycloak import KeycloakAuth
|
||||
|
||||
custom_verifier = JWTVerifier(
|
||||
jwks_uri="http://localhost:8080/realms/myrealm/protocol/openid-connect/certs",
|
||||
|
|
@ -133,9 +135,11 @@ custom_verifier = JWTVerifier(
|
|||
required_scopes=["api:read", "api:write"],
|
||||
)
|
||||
|
||||
auth = KeycloakAuthProvider(
|
||||
realm_url="http://localhost:8080/realms/myrealm",
|
||||
base_url="http://localhost:8000",
|
||||
auth_plugin = KeycloakAuth(
|
||||
KeycloakAuth.Config(
|
||||
realm_url="http://localhost:8080/realms/myrealm",
|
||||
base_url="http://localhost:8000",
|
||||
),
|
||||
token_verifier=custom_verifier,
|
||||
)
|
||||
```
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ Follow the Steps as mentioned below to create an OAuth client.
|
|||
Click on "Submit" button to update OAuth configuration for the client application.
|
||||
**Note: You don't need to do any special configuration to support PKCE for the OAuth client.**
|
||||
Make sure to Activate the client application.
|
||||
Note down client ID and client secret for the application. You'll use these values when configuring the OCIProvider in your code.
|
||||
Note down client ID and client secret for the application. You'll use these values when configuring the OCI plugin in your code.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
|
|
@ -209,7 +209,7 @@ For production deployments with persistent token management across server restar
|
|||
|
||||
import os
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.oci import OCIProvider
|
||||
from fastmcp.server.plugins.auth.oci import OCIAuth
|
||||
|
||||
from key_value.aio.stores.redis import RedisStore
|
||||
from key_value.aio.wrappers.encryption import FernetEncryptionWrapper
|
||||
|
|
@ -217,14 +217,14 @@ from cryptography.fernet import Fernet
|
|||
|
||||
# Load configuration from environment
|
||||
# Production setup with encrypted persistent token storage
|
||||
auth_provider = OCIProvider(
|
||||
config_url=os.environ.get("OCI_CONFIG_URL"),
|
||||
client_id=os.environ.get("OCI_CLIENT_ID"),
|
||||
client_secret=os.environ.get("OCI_CLIENT_SECRET"),
|
||||
base_url=os.environ.get("BASE_URL", "https://your-production-domain.com"),
|
||||
|
||||
# Production token management
|
||||
jwt_signing_key=os.environ["JWT_SIGNING_KEY"],
|
||||
auth_plugin = OCIAuth(
|
||||
OCIAuth.Config(
|
||||
config_url=os.environ.get("OCI_CONFIG_URL"),
|
||||
client_id=os.environ.get("OCI_CLIENT_ID"),
|
||||
client_secret=os.environ.get("OCI_CLIENT_SECRET"),
|
||||
base_url=os.environ.get("BASE_URL", "https://your-production-domain.com"),
|
||||
jwt_signing_key=os.environ["JWT_SIGNING_KEY"],
|
||||
),
|
||||
client_storage=FernetEncryptionWrapper(
|
||||
key_value=RedisStore(
|
||||
host=os.environ["REDIS_HOST"],
|
||||
|
|
@ -234,7 +234,7 @@ auth_provider = OCIProvider(
|
|||
)
|
||||
)
|
||||
|
||||
mcp = FastMCP(name="Production OCI App", auth=auth_provider)
|
||||
mcp = FastMCP(name="Production OCI App", plugins=[auth_plugin])
|
||||
```
|
||||
|
||||
<Note>
|
||||
|
|
@ -245,4 +245,4 @@ For complete details on these parameters, see the [OAuth Proxy documentation](/s
|
|||
|
||||
<Info>
|
||||
The client caches tokens locally, so you won't need to re-authenticate for subsequent runs unless the token expires or you explicitly clear the cache.
|
||||
</Info>
|
||||
</Info>
|
||||
|
|
|
|||
|
|
@ -67,22 +67,24 @@ SERVER_URL=http://localhost:8000 # Your server's base U
|
|||
|
||||
### Step 3: FastMCP Configuration
|
||||
|
||||
Create your FastMCP server file and use the PropelAuthProvider to handle all the OAuth integration automatically:
|
||||
Create your FastMCP server file and use the `PropelAuth` plugin to handle the OAuth integration automatically:
|
||||
|
||||
```python server.py
|
||||
import os
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.propelauth import PropelAuthProvider
|
||||
from fastmcp.server.plugins.auth.propelauth import PropelAuth
|
||||
|
||||
auth_provider = PropelAuthProvider(
|
||||
auth_url=os.environ["PROPELAUTH_AUTH_URL"],
|
||||
introspection_client_id=os.environ["PROPELAUTH_INTROSPECTION_CLIENT_ID"],
|
||||
introspection_client_secret=os.environ["PROPELAUTH_INTROSPECTION_CLIENT_SECRET"],
|
||||
base_url=os.environ["SERVER_URL"],
|
||||
required_scopes=["read:user_data"], # Optional scope enforcement
|
||||
auth_plugin = PropelAuth(
|
||||
PropelAuth.Config(
|
||||
auth_url=os.environ["PROPELAUTH_AUTH_URL"],
|
||||
introspection_client_id=os.environ["PROPELAUTH_INTROSPECTION_CLIENT_ID"],
|
||||
introspection_client_secret=os.environ["PROPELAUTH_INTROSPECTION_CLIENT_SECRET"],
|
||||
base_url=os.environ["SERVER_URL"],
|
||||
required_scopes=["read:user_data"], # Optional scope enforcement
|
||||
)
|
||||
)
|
||||
|
||||
mcp = FastMCP(name="My PropelAuth Protected Server", auth=auth_provider)
|
||||
mcp = FastMCP(name="My PropelAuth Protected Server", plugins=[auth_plugin])
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
|
@ -114,18 +116,20 @@ You can use `get_access_token()` inside your tools to identify the authenticated
|
|||
```python server.py
|
||||
import os
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.propelauth import PropelAuthProvider
|
||||
from fastmcp.server.plugins.auth.propelauth import PropelAuth
|
||||
from fastmcp.server.dependencies import get_access_token
|
||||
|
||||
auth = PropelAuthProvider(
|
||||
auth_url=os.environ["PROPELAUTH_AUTH_URL"],
|
||||
introspection_client_id=os.environ["PROPELAUTH_INTROSPECTION_CLIENT_ID"],
|
||||
introspection_client_secret=os.environ["PROPELAUTH_INTROSPECTION_CLIENT_SECRET"],
|
||||
base_url=os.environ["SERVER_URL"],
|
||||
required_scopes=["read:user_data"],
|
||||
auth_plugin = PropelAuth(
|
||||
PropelAuth.Config(
|
||||
auth_url=os.environ["PROPELAUTH_AUTH_URL"],
|
||||
introspection_client_id=os.environ["PROPELAUTH_INTROSPECTION_CLIENT_ID"],
|
||||
introspection_client_secret=os.environ["PROPELAUTH_INTROSPECTION_CLIENT_SECRET"],
|
||||
base_url=os.environ["SERVER_URL"],
|
||||
required_scopes=["read:user_data"],
|
||||
)
|
||||
)
|
||||
|
||||
mcp = FastMCP(name="My PropelAuth Protected Server", auth=auth)
|
||||
mcp = FastMCP(name="My PropelAuth Protected Server", plugins=[auth_plugin])
|
||||
|
||||
@mcp.tool
|
||||
def whoami() -> dict:
|
||||
|
|
@ -139,26 +143,26 @@ def whoami() -> dict:
|
|||
|
||||
## Advanced Configuration
|
||||
|
||||
The `PropelAuthProvider` supports optional overrides for token introspection behavior, including caching and request timeouts:
|
||||
The `PropelAuth` plugin supports optional overrides for token introspection behavior, including caching and request timeouts:
|
||||
|
||||
```python server.py
|
||||
import os
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.propelauth import PropelAuthProvider
|
||||
from fastmcp.server.plugins.auth.propelauth import PropelAuth
|
||||
|
||||
auth = PropelAuthProvider(
|
||||
auth_url=os.environ["PROPELAUTH_AUTH_URL"],
|
||||
introspection_client_id=os.environ["PROPELAUTH_INTROSPECTION_CLIENT_ID"],
|
||||
introspection_client_secret=os.environ["PROPELAUTH_INTROSPECTION_CLIENT_SECRET"],
|
||||
base_url=os.environ.get("BASE_URL", "https://your-server.com"),
|
||||
required_scopes=["read:user_data"],
|
||||
resource="https://your-server.com/mcp", # Restrict to tokens intended for this server (RFC 8707)
|
||||
token_introspection_overrides={
|
||||
"cache_ttl_seconds": 300, # Cache introspection results for 5 minutes
|
||||
"max_cache_size": 1000, # Maximum cached tokens
|
||||
"timeout_seconds": 15, # HTTP request timeout
|
||||
},
|
||||
auth_plugin = PropelAuth(
|
||||
PropelAuth.Config(
|
||||
auth_url=os.environ["PROPELAUTH_AUTH_URL"],
|
||||
introspection_client_id=os.environ["PROPELAUTH_INTROSPECTION_CLIENT_ID"],
|
||||
introspection_client_secret=os.environ["PROPELAUTH_INTROSPECTION_CLIENT_SECRET"],
|
||||
base_url=os.environ.get("BASE_URL", "https://your-server.com"),
|
||||
required_scopes=["read:user_data"],
|
||||
resource="https://your-server.com/mcp", # Restrict to tokens intended for this server (RFC 8707)
|
||||
introspection_cache_ttl_seconds=300, # Cache introspection results for 5 minutes
|
||||
introspection_max_cache_size=1000, # Maximum cached tokens
|
||||
introspection_timeout_seconds=15, # HTTP request timeout
|
||||
)
|
||||
)
|
||||
|
||||
mcp = FastMCP(name="My PropelAuth Protected Server", auth=auth)
|
||||
mcp = FastMCP(name="My PropelAuth Protected Server", plugins=[auth_plugin])
|
||||
```
|
||||
|
|
|
|||
|
|
@ -43,24 +43,26 @@ BASE_URL=http://localhost:8000/
|
|||
|
||||
### Step 2: Add auth to FastMCP server
|
||||
|
||||
Create your FastMCP server file and use the ScalekitProvider to handle all the OAuth integration automatically:
|
||||
Create your FastMCP server file and use the `ScalekitAuth` plugin to handle the OAuth integration automatically:
|
||||
|
||||
> **Warning:** The legacy `mcp_url` and `client_id` parameters are deprecated and will be removed in a future release. Use `base_url` instead of `mcp_url` and remove `client_id` from your configuration.
|
||||
|
||||
```python server.py
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.scalekit import ScalekitProvider
|
||||
from fastmcp.server.plugins.auth.scalekit import ScalekitAuth
|
||||
|
||||
# Discovers Scalekit endpoints and set up JWT token validation
|
||||
auth_provider = ScalekitProvider(
|
||||
environment_url=SCALEKIT_ENVIRONMENT_URL, # Scalekit environment URL
|
||||
resource_id=SCALEKIT_RESOURCE_ID, # Resource server ID
|
||||
base_url=SERVER_URL, # Public MCP endpoint
|
||||
required_scopes=["read"], # Optional scope enforcement
|
||||
auth_plugin = ScalekitAuth(
|
||||
ScalekitAuth.Config(
|
||||
environment_url=SCALEKIT_ENVIRONMENT_URL, # Scalekit environment URL
|
||||
resource_id=SCALEKIT_RESOURCE_ID, # Resource server ID
|
||||
base_url=SERVER_URL, # Public MCP endpoint
|
||||
required_scopes=["read"], # Optional scope enforcement
|
||||
)
|
||||
)
|
||||
|
||||
# Create FastMCP server with auth
|
||||
mcp = FastMCP(name="My Scalekit Protected Server", auth=auth_provider)
|
||||
# Create FastMCP server with auth plugin
|
||||
mcp = FastMCP(name="My Scalekit Protected Server", plugins=[auth_plugin])
|
||||
|
||||
@mcp.tool
|
||||
def auth_status() -> dict:
|
||||
|
|
@ -95,16 +97,18 @@ For production deployments, load configuration from environment variables:
|
|||
```python server.py
|
||||
import os
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.scalekit import ScalekitProvider
|
||||
from fastmcp.server.plugins.auth.scalekit import ScalekitAuth
|
||||
|
||||
# Load configuration from environment variables
|
||||
auth = ScalekitProvider(
|
||||
environment_url=os.environ.get("SCALEKIT_ENVIRONMENT_URL"),
|
||||
resource_id=os.environ.get("SCALEKIT_RESOURCE_ID"),
|
||||
base_url=os.environ.get("BASE_URL", "https://your-server.com")
|
||||
auth_plugin = ScalekitAuth(
|
||||
ScalekitAuth.Config(
|
||||
environment_url=os.environ.get("SCALEKIT_ENVIRONMENT_URL"),
|
||||
resource_id=os.environ.get("SCALEKIT_RESOURCE_ID"),
|
||||
base_url=os.environ.get("BASE_URL", "https://your-server.com"),
|
||||
)
|
||||
)
|
||||
|
||||
mcp = FastMCP(name="My Scalekit Protected Server", auth=auth)
|
||||
mcp = FastMCP(name="My Scalekit Protected Server", plugins=[auth_plugin])
|
||||
|
||||
@mcp.tool
|
||||
def protected_action() -> str:
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ Supabase Auth does not currently support [RFC 8707](https://www.rfc-editor.org/r
|
|||
|
||||
Supabase's OAuth Server delegates the user consent screen to your application. When an MCP client initiates authorization, Supabase authenticates the user and then redirects to your application at a configured callback URL (e.g., `https://your-app.com/oauth/callback?authorization_id=...`). Your application must host a page that calls Supabase's `approveAuthorization()` or `denyAuthorization()` APIs to complete the flow.
|
||||
|
||||
`SupabaseProvider` handles the resource server side (token verification and metadata), but you are responsible for building and hosting the consent UI separately. See [Supabase's OAuth Server documentation](https://supabase.com/docs/guides/auth/oauth-server/getting-started) for details on implementing the authorization page.
|
||||
The Supabase auth plugin handles the resource server side (token verification and metadata), but you are responsible for building and hosting the consent UI separately. See [Supabase's OAuth Server documentation](https://supabase.com/docs/guides/auth/oauth-server/getting-started) for details on implementing the authorization page.
|
||||
|
||||
## Configuration
|
||||
|
||||
|
|
@ -49,18 +49,20 @@ In your Supabase Dashboard:
|
|||
|
||||
### Step 3: FastMCP Configuration
|
||||
|
||||
Create your FastMCP server using the `SupabaseProvider`:
|
||||
Create your FastMCP server using the `SupabaseAuth` plugin:
|
||||
|
||||
```python server.py
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.supabase import SupabaseProvider
|
||||
from fastmcp.server.plugins.auth.supabase import SupabaseAuth
|
||||
|
||||
auth = SupabaseProvider(
|
||||
project_url="https://abc123.supabase.co",
|
||||
base_url="http://localhost:8000",
|
||||
auth_plugin = SupabaseAuth(
|
||||
SupabaseAuth.Config(
|
||||
project_url="https://abc123.supabase.co",
|
||||
base_url="http://localhost:8000",
|
||||
)
|
||||
)
|
||||
|
||||
mcp = FastMCP("Supabase Protected Server", auth=auth)
|
||||
mcp = FastMCP("Supabase Protected Server", plugins=[auth_plugin])
|
||||
|
||||
@mcp.tool
|
||||
def protected_tool(message: str) -> str:
|
||||
|
|
@ -112,12 +114,14 @@ For production deployments, load configuration from environment variables:
|
|||
```python server.py
|
||||
import os
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.supabase import SupabaseProvider
|
||||
from fastmcp.server.plugins.auth.supabase import SupabaseAuth
|
||||
|
||||
auth = SupabaseProvider(
|
||||
project_url=os.environ["SUPABASE_PROJECT_URL"],
|
||||
base_url=os.environ.get("BASE_URL", "https://your-server.com"),
|
||||
auth_plugin = SupabaseAuth(
|
||||
SupabaseAuth.Config(
|
||||
project_url=os.environ["SUPABASE_PROJECT_URL"],
|
||||
base_url=os.environ.get("BASE_URL", "https://your-server.com"),
|
||||
)
|
||||
)
|
||||
|
||||
mcp = FastMCP(name="Supabase Secured App", auth=auth)
|
||||
mcp = FastMCP(name="Supabase Secured App", plugins=[auth_plugin])
|
||||
```
|
||||
|
|
|
|||
|
|
@ -56,22 +56,24 @@ The callback URL must match exactly. The default path is `/auth/callback`, but y
|
|||
|
||||
### Step 2: FastMCP Configuration
|
||||
|
||||
Create your FastMCP server using the `WorkOSProvider`:
|
||||
Create your FastMCP server using the `WorkOSAuth` plugin:
|
||||
|
||||
```python server.py
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.workos import WorkOSProvider
|
||||
from fastmcp.server.plugins.auth.workos import WorkOSAuth
|
||||
|
||||
# Configure WorkOS OAuth
|
||||
auth = WorkOSProvider(
|
||||
client_id="client_YOUR_CLIENT_ID",
|
||||
client_secret="YOUR_CLIENT_SECRET",
|
||||
authkit_domain="https://your-app.authkit.app",
|
||||
base_url="http://localhost:8000",
|
||||
required_scopes=["openid", "profile", "email"]
|
||||
auth_plugin = WorkOSAuth(
|
||||
WorkOSAuth.Config(
|
||||
client_id="client_YOUR_CLIENT_ID",
|
||||
client_secret="YOUR_CLIENT_SECRET",
|
||||
authkit_domain="https://your-app.authkit.app",
|
||||
base_url="http://localhost:8000",
|
||||
required_scopes=["openid", "profile", "email"],
|
||||
)
|
||||
)
|
||||
|
||||
mcp = FastMCP("WorkOS Protected Server", auth=auth)
|
||||
mcp = FastMCP("WorkOS Protected Server", plugins=[auth_plugin])
|
||||
|
||||
@mcp.tool
|
||||
def protected_tool(message: str) -> str:
|
||||
|
|
@ -134,21 +136,21 @@ For production deployments with persistent token management across server restar
|
|||
```python server.py
|
||||
import os
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.workos import WorkOSProvider
|
||||
from fastmcp.server.plugins.auth.workos import WorkOSAuth
|
||||
from key_value.aio.stores.redis import RedisStore
|
||||
from key_value.aio.wrappers.encryption import FernetEncryptionWrapper
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
# Production setup with encrypted persistent token storage
|
||||
auth = WorkOSProvider(
|
||||
client_id="client_YOUR_CLIENT_ID",
|
||||
client_secret="YOUR_CLIENT_SECRET",
|
||||
authkit_domain="https://your-app.authkit.app",
|
||||
base_url="https://your-production-domain.com",
|
||||
required_scopes=["openid", "profile", "email"],
|
||||
|
||||
# Production token management
|
||||
jwt_signing_key=os.environ["JWT_SIGNING_KEY"],
|
||||
auth_plugin = WorkOSAuth(
|
||||
WorkOSAuth.Config(
|
||||
client_id="client_YOUR_CLIENT_ID",
|
||||
client_secret="YOUR_CLIENT_SECRET",
|
||||
authkit_domain="https://your-app.authkit.app",
|
||||
base_url="https://your-production-domain.com",
|
||||
required_scopes=["openid", "profile", "email"],
|
||||
jwt_signing_key=os.environ["JWT_SIGNING_KEY"],
|
||||
),
|
||||
client_storage=FernetEncryptionWrapper(
|
||||
key_value=RedisStore(
|
||||
host=os.environ["REDIS_HOST"],
|
||||
|
|
@ -158,7 +160,7 @@ auth = WorkOSProvider(
|
|||
)
|
||||
)
|
||||
|
||||
mcp = FastMCP(name="Production WorkOS App", auth=auth)
|
||||
mcp = FastMCP(name="Production WorkOS App", plugins=[auth_plugin])
|
||||
```
|
||||
|
||||
<Note>
|
||||
|
|
@ -197,4 +199,4 @@ OAuth callback path
|
|||
<ParamField path="timeout_seconds" default="10">
|
||||
API request timeout
|
||||
</ParamField>
|
||||
</Card>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ For example, the built-in `AuthKitProvider` uses WorkOS AuthKit, which fully sup
|
|||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.workos import AuthKitProvider
|
||||
from fastmcp.server.plugins.auth.authkit.provider import AuthKitProvider
|
||||
|
||||
auth = AuthKitProvider(
|
||||
authkit_domain="https://your-project.authkit.app",
|
||||
|
|
@ -136,7 +136,7 @@ For example, the built-in `GitHubProvider` extends `OAuthProxy` to work with Git
|
|||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.github import GitHubProvider
|
||||
from fastmcp.server.plugins.auth.github.provider import GitHubProvider
|
||||
|
||||
auth = GitHubProvider(
|
||||
client_id="Ov23li...", # Your GitHub OAuth App ID
|
||||
|
|
@ -221,7 +221,7 @@ For production deployments, load sensitive values like client secrets from envir
|
|||
```python
|
||||
import os
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.github import GitHubProvider
|
||||
from fastmcp.server.plugins.auth.github.provider import GitHubProvider
|
||||
|
||||
# Load secrets from environment variables
|
||||
auth = GitHubProvider(
|
||||
|
|
|
|||
|
|
@ -355,7 +355,7 @@ auth = OAuthProxy(..., client_storage=MemoryStore())
|
|||
FastMCP includes pre-configured providers for common services:
|
||||
|
||||
```python
|
||||
from fastmcp.server.auth.providers.github import GitHubProvider
|
||||
from fastmcp.server.plugins.auth.github.provider import GitHubProvider
|
||||
|
||||
auth = GitHubProvider(
|
||||
client_id="your-github-app-id",
|
||||
|
|
@ -690,7 +690,7 @@ For production deployments, load sensitive credentials from environment variable
|
|||
```python
|
||||
import os
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.github import GitHubProvider
|
||||
from fastmcp.server.plugins.auth.github.provider import GitHubProvider
|
||||
|
||||
# Load secrets from environment variables
|
||||
auth = GitHubProvider(
|
||||
|
|
|
|||
|
|
@ -218,7 +218,7 @@ auth = OIDCProxy(
|
|||
FastMCP includes pre-configured OIDC providers for common services:
|
||||
|
||||
```python
|
||||
from fastmcp.server.auth.providers.auth0 import Auth0Provider
|
||||
from fastmcp.server.plugins.auth.auth0.provider import Auth0Provider
|
||||
|
||||
auth = Auth0Provider(
|
||||
config_url="https://.../.well-known/openid-configuration",
|
||||
|
|
@ -262,7 +262,7 @@ For production deployments, load sensitive credentials from environment variable
|
|||
```python
|
||||
import os
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.auth0 import Auth0Provider
|
||||
from fastmcp.server.plugins.auth.auth0.provider import Auth0Provider
|
||||
|
||||
# Load secrets from environment variables
|
||||
auth = Auth0Provider(
|
||||
|
|
|
|||
|
|
@ -10,68 +10,53 @@ Use an auth plugin when you want authentication to be configured alongside other
|
|||
|
||||
```python server.py
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.plugins.auth import GitHubAuth
|
||||
from fastmcp.server.plugins.auth.github import GitHubAuth
|
||||
|
||||
mcp = FastMCP(
|
||||
"GitHub Protected Server",
|
||||
plugins=[
|
||||
GitHubAuth(
|
||||
{
|
||||
"client_id": "your-github-client-id",
|
||||
"client_secret": "your-github-client-secret",
|
||||
"base_url": "https://your-server.com",
|
||||
}
|
||||
GitHubAuth.Config(
|
||||
client_id="your-github-client-id",
|
||||
client_secret="your-github-client-secret",
|
||||
base_url="https://your-server.com",
|
||||
)
|
||||
)
|
||||
],
|
||||
)
|
||||
```
|
||||
|
||||
The provider APIs remain available and are still the most direct option in Python code:
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.github import GitHubProvider
|
||||
|
||||
auth = GitHubProvider(
|
||||
client_id="your-github-client-id",
|
||||
client_secret="your-github-client-secret",
|
||||
base_url="https://your-server.com",
|
||||
)
|
||||
|
||||
mcp = FastMCP("GitHub Protected Server", auth=auth)
|
||||
```
|
||||
Provider APIs remain available in each plugin's explicit `.provider` module for advanced direct auth wiring, but integrations should prefer the plugin form.
|
||||
|
||||
## Included Plugins
|
||||
|
||||
Import first-party auth plugins from `fastmcp.server.plugins.auth`:
|
||||
Each first-party auth plugin lives in its own module under `fastmcp.server.plugins.auth`, mirroring the provider package:
|
||||
|
||||
```python
|
||||
from fastmcp.server.plugins.auth import (
|
||||
Auth0Auth,
|
||||
AuthKitAuth,
|
||||
AWSCognitoAuth,
|
||||
AzureAuth,
|
||||
ClerkAuth,
|
||||
DescopeAuth,
|
||||
DiscordAuth,
|
||||
GitHubAuth,
|
||||
GoogleAuth,
|
||||
KeycloakAuth,
|
||||
OCIAuth,
|
||||
PropelAuth,
|
||||
ScalekitAuth,
|
||||
SupabaseAuth,
|
||||
WorkOSAuth,
|
||||
)
|
||||
from fastmcp.server.plugins.auth.auth0 import Auth0Auth
|
||||
from fastmcp.server.plugins.auth.authkit import AuthKitAuth
|
||||
from fastmcp.server.plugins.auth.aws import AWSCognitoAuth
|
||||
from fastmcp.server.plugins.auth.azure import AzureAuth
|
||||
from fastmcp.server.plugins.auth.clerk import ClerkAuth
|
||||
from fastmcp.server.plugins.auth.descope import DescopeAuth
|
||||
from fastmcp.server.plugins.auth.discord import DiscordAuth
|
||||
from fastmcp.server.plugins.auth.github import GitHubAuth
|
||||
from fastmcp.server.plugins.auth.google import GoogleAuth
|
||||
from fastmcp.server.plugins.auth.keycloak import KeycloakAuth
|
||||
from fastmcp.server.plugins.auth.oci import OCIAuth
|
||||
from fastmcp.server.plugins.auth.propelauth import PropelAuth
|
||||
from fastmcp.server.plugins.auth.scalekit import ScalekitAuth
|
||||
from fastmcp.server.plugins.auth.supabase import SupabaseAuth
|
||||
from fastmcp.server.plugins.auth.workos import WorkOSAuth
|
||||
```
|
||||
|
||||
Each plugin accepts a matching `*AuthConfig` model or a plain dictionary. Config fields mirror the wrapped provider's constructor wherever the value can be represented as JSON. Python-only objects such as custom token verifiers, HTTP clients, and client storage are passed as constructor keyword arguments:
|
||||
Each plugin exposes its serializable configuration model as `Plugin.Config`. Config fields mirror the wrapped provider's constructor wherever the value can be represented as JSON. Python-only objects such as custom token verifiers, HTTP clients, and client storage are passed as constructor keyword arguments:
|
||||
|
||||
```python
|
||||
from fastmcp.server.plugins.auth import SupabaseAuth, SupabaseAuthConfig
|
||||
from fastmcp.server.plugins.auth.supabase import SupabaseAuth
|
||||
|
||||
auth_plugin = SupabaseAuth(
|
||||
SupabaseAuthConfig(
|
||||
SupabaseAuth.Config(
|
||||
project_url="https://abc123.supabase.co",
|
||||
base_url="https://your-server.com",
|
||||
required_scopes=["read"],
|
||||
|
|
|
|||
|
|
@ -197,7 +197,7 @@ WorkOS AuthKit provides an excellent example of remote OAuth integration. The `A
|
|||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.workos import AuthKitProvider
|
||||
from fastmcp.server.plugins.auth.authkit.provider import AuthKitProvider
|
||||
|
||||
auth = AuthKitProvider(
|
||||
authkit_domain="https://your-project.authkit.app",
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ For OAuth token storage:
|
|||
|
||||
```python
|
||||
import os
|
||||
from fastmcp.server.auth.providers.github import GitHubProvider
|
||||
from fastmcp.server.plugins.auth.github.provider import GitHubProvider
|
||||
from key_value.aio.stores.redis import RedisStore
|
||||
|
||||
auth = GitHubProvider(
|
||||
|
|
@ -163,7 +163,7 @@ By default, FastMCP automatically manages keys and storage based on your platfor
|
|||
No configuration needed:
|
||||
|
||||
```python
|
||||
from fastmcp.server.auth.providers.github import GitHubProvider
|
||||
from fastmcp.server.plugins.auth.github.provider import GitHubProvider
|
||||
|
||||
auth = GitHubProvider(
|
||||
client_id="your-id",
|
||||
|
|
@ -178,7 +178,7 @@ For production deployments, configure explicit keys and persistent network-acces
|
|||
|
||||
```python
|
||||
import os
|
||||
from fastmcp.server.auth.providers.github import GitHubProvider
|
||||
from fastmcp.server.plugins.auth.github.provider import GitHubProvider
|
||||
from key_value.aio.stores.redis import RedisStore
|
||||
from key_value.aio.wrappers.encryption import FernetEncryptionWrapper
|
||||
from cryptography.fernet import Fernet
|
||||
|
|
|
|||
|
|
@ -468,7 +468,7 @@ When mounting an OAuth-protected server under a path prefix, declare your URLs u
|
|||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.github import GitHubProvider
|
||||
from fastmcp.server.plugins.auth.github.provider import GitHubProvider
|
||||
from starlette.applications import Starlette
|
||||
from starlette.routing import Mount
|
||||
|
||||
|
|
@ -530,7 +530,7 @@ Here's a complete working example showing all the pieces together:
|
|||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.github import GitHubProvider
|
||||
from fastmcp.server.plugins.auth.github.provider import GitHubProvider
|
||||
from starlette.applications import Starlette
|
||||
from starlette.routing import Mount
|
||||
import uvicorn
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ Create your FastMCP server using the `Auth0Provider`.
|
|||
|
||||
```python server.py
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.auth0 import Auth0Provider
|
||||
from fastmcp.server.plugins.auth.auth0.provider import Auth0Provider
|
||||
|
||||
# The Auth0Provider utilizes Auth0 OIDC configuration
|
||||
auth_provider = Auth0Provider(
|
||||
|
|
@ -158,7 +158,7 @@ For production deployments with persistent token management across server restar
|
|||
```python server.py
|
||||
import os
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.auth0 import Auth0Provider
|
||||
from fastmcp.server.plugins.auth.auth0.provider import Auth0Provider
|
||||
from key_value.aio.stores.redis import RedisStore
|
||||
from key_value.aio.wrappers.encryption import FernetEncryptionWrapper
|
||||
from cryptography.fernet import Fernet
|
||||
|
|
@ -205,7 +205,7 @@ Setting this environment variable allows the Auth0 provider to be used automatic
|
|||
|
||||
<Card>
|
||||
<ParamField path="FASTMCP_SERVER_AUTH" default="Not set">
|
||||
Set to `fastmcp.server.auth.providers.auth0.Auth0Provider` to use Auth0 authentication.
|
||||
Set to `fastmcp.server.plugins.auth.auth0.provider.Auth0Provider` to use Auth0 authentication.
|
||||
</ParamField>
|
||||
</Card>
|
||||
|
||||
|
|
@ -250,7 +250,7 @@ Comma-, space-, or JSON-separated list of required AUth0 scopes (e.g., `openid e
|
|||
Example `.env` file:
|
||||
```bash
|
||||
# Use the Auth0 provider
|
||||
FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.auth0.Auth0Provider
|
||||
FASTMCP_SERVER_AUTH=fastmcp.server.plugins.auth.auth0.provider.Auth0Provider
|
||||
|
||||
# Auth0 configuration and credentials
|
||||
FASTMCP_SERVER_AUTH_AUTH0_CONFIG_URL=https://.../.well-known/openid-configuration
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ Create your FastMCP server file and use the `AuthKitProvider` to handle all the
|
|||
|
||||
```python server.py
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.workos import AuthKitProvider
|
||||
from fastmcp.server.plugins.auth.authkit.provider import AuthKitProvider
|
||||
|
||||
# The AuthKitProvider automatically discovers WorkOS endpoints
|
||||
# and configures JWT token validation
|
||||
|
|
@ -90,7 +90,7 @@ Setting this environment variable allows the AuthKit provider to be used automat
|
|||
|
||||
<Card>
|
||||
<ParamField path="FASTMCP_SERVER_AUTH" default="Not set">
|
||||
Set to `fastmcp.server.auth.providers.workos.AuthKitProvider` to use AuthKit authentication.
|
||||
Set to `fastmcp.server.plugins.auth.authkit.provider.AuthKitProvider` to use AuthKit authentication.
|
||||
</ParamField>
|
||||
</Card>
|
||||
|
||||
|
|
@ -115,7 +115,7 @@ Comma-, space-, or JSON-separated list of required OAuth scopes (e.g., `openid p
|
|||
Example `.env` file:
|
||||
```bash
|
||||
# Use the AuthKit provider
|
||||
FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.workos.AuthKitProvider
|
||||
FASTMCP_SERVER_AUTH=fastmcp.server.plugins.auth.authkit.provider.AuthKitProvider
|
||||
|
||||
# AuthKit configuration
|
||||
FASTMCP_SERVER_AUTH_AUTHKITPROVIDER_AUTHKIT_DOMAIN=https://your-project-12345.authkit.app
|
||||
|
|
|
|||
|
|
@ -121,7 +121,7 @@ Create your FastMCP server using the `AWSCognitoProvider`, which handles AWS Cog
|
|||
|
||||
```python server.py
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.aws import AWSCognitoProvider
|
||||
from fastmcp.server.plugins.auth.aws.provider import AWSCognitoProvider
|
||||
from fastmcp.server.dependencies import get_access_token
|
||||
|
||||
# The AWSCognitoProvider handles JWT validation and user claims
|
||||
|
|
@ -205,7 +205,7 @@ For production deployments with persistent token management across server restar
|
|||
```python server.py
|
||||
import os
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.aws import AWSCognitoProvider
|
||||
from fastmcp.server.plugins.auth.aws.provider import AWSCognitoProvider
|
||||
from key_value.aio.stores.redis import RedisStore
|
||||
from key_value.aio.wrappers.encryption import FernetEncryptionWrapper
|
||||
from cryptography.fernet import Fernet
|
||||
|
|
@ -248,7 +248,7 @@ Setting this environment variable allows the AWS Cognito provider to be used aut
|
|||
|
||||
<Card>
|
||||
<ParamField path="FASTMCP_SERVER_AUTH" default="Not set">
|
||||
Set to `fastmcp.server.auth.providers.aws.AWSCognitoProvider` to use AWS Cognito authentication.
|
||||
Set to `fastmcp.server.plugins.auth.aws.provider.AWSCognitoProvider` to use AWS Cognito authentication.
|
||||
</ParamField>
|
||||
</Card>
|
||||
|
||||
|
|
@ -293,7 +293,7 @@ Comma-, space-, or JSON-separated list of required OAuth scopes (e.g., `openid e
|
|||
Example `.env` file:
|
||||
```bash
|
||||
# Use the AWS Cognito provider
|
||||
FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.aws.AWSCognitoProvider
|
||||
FASTMCP_SERVER_AUTH=fastmcp.server.plugins.auth.aws.provider.AWSCognitoProvider
|
||||
|
||||
# AWS Cognito credentials
|
||||
FASTMCP_SERVER_AUTH_AWS_COGNITO_USER_POOL_ID=eu-central-1_XXXXXXXXX
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@ Create your FastMCP server using the `AzureProvider`, which handles Azure's OAut
|
|||
|
||||
```python server.py
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.azure import AzureProvider
|
||||
from fastmcp.server.plugins.auth.azure.provider import AzureProvider
|
||||
|
||||
# The AzureProvider handles Azure's token format and validation
|
||||
auth_provider = AzureProvider(
|
||||
|
|
@ -247,7 +247,7 @@ For production deployments with persistent token management across server restar
|
|||
```python server.py
|
||||
import os
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.azure import AzureProvider
|
||||
from fastmcp.server.plugins.auth.azure.provider import AzureProvider
|
||||
from key_value.aio.stores.redis import RedisStore
|
||||
from key_value.aio.wrappers.encryption import FernetEncryptionWrapper
|
||||
from cryptography.fernet import Fernet
|
||||
|
|
@ -292,7 +292,7 @@ Setting this environment variable allows the Azure provider to be used automatic
|
|||
|
||||
<Card>
|
||||
<ParamField path="FASTMCP_SERVER_AUTH" default="Not set">
|
||||
Set to `fastmcp.server.auth.providers.azure.AzureProvider` to use Azure authentication.
|
||||
Set to `fastmcp.server.plugins.auth.azure.provider.AzureProvider` to use Azure authentication.
|
||||
</ParamField>
|
||||
</Card>
|
||||
|
||||
|
|
@ -360,7 +360,7 @@ This setting affects all Azure OAuth endpoints (authorization, token, issuer, JW
|
|||
Example `.env` file:
|
||||
```bash
|
||||
# Use the Azure provider
|
||||
FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.azure.AzureProvider
|
||||
FASTMCP_SERVER_AUTH=fastmcp.server.plugins.auth.azure.provider.AzureProvider
|
||||
|
||||
# Azure OAuth credentials
|
||||
FASTMCP_SERVER_AUTH_AZURE_CLIENT_ID=835f09b6-0f0f-40cc-85cb-f32c5829a149
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ Create your FastMCP server file and use the DescopeProvider to handle all the OA
|
|||
|
||||
```python server.py
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.descope import DescopeProvider
|
||||
from fastmcp.server.plugins.auth.descope.provider import DescopeProvider
|
||||
|
||||
# The DescopeProvider automatically discovers Descope endpoints
|
||||
# and configures JWT token validation
|
||||
|
|
@ -105,7 +105,7 @@ Setting this environment variable allows the Descope provider to be used automat
|
|||
|
||||
<Card>
|
||||
<ParamField path="FASTMCP_SERVER_AUTH" default="Not set">
|
||||
Set to `fastmcp.server.auth.providers.descope.DescopeProvider` to use
|
||||
Set to `fastmcp.server.plugins.auth.descope.provider.DescopeProvider` to use
|
||||
Descope authentication.
|
||||
</ParamField>
|
||||
</Card>
|
||||
|
|
@ -129,7 +129,7 @@ Example `.env` file:
|
|||
|
||||
```bash
|
||||
# Use the Descope provider
|
||||
FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.descope.DescopeProvider
|
||||
FASTMCP_SERVER_AUTH=fastmcp.server.plugins.auth.descope.provider.DescopeProvider
|
||||
|
||||
# Descope configuration
|
||||
FASTMCP_SERVER_AUTH_DESCOPEPROVIDER_CONFIG_URL=https://.../v1/apps/agentic/P.../M.../.well-known/openid-configuration
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ Create your FastMCP server using the `DiscordProvider`, which handles Discord's
|
|||
|
||||
```python server.py
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.discord import DiscordProvider
|
||||
from fastmcp.server.plugins.auth.discord.provider import DiscordProvider
|
||||
|
||||
auth_provider = DiscordProvider(
|
||||
client_id="12345", # Your Discord Application Client ID
|
||||
|
|
@ -154,7 +154,7 @@ For production deployments with persistent token management across server restar
|
|||
```python server.py
|
||||
import os
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.discord import DiscordProvider
|
||||
from fastmcp.server.plugins.auth.discord.provider import DiscordProvider
|
||||
from key_value.aio.stores.redis import RedisStore
|
||||
from key_value.aio.wrappers.encryption import FernetEncryptionWrapper
|
||||
from cryptography.fernet import Fernet
|
||||
|
|
@ -193,7 +193,7 @@ Setting this environment variable allows the Discord provider to be used automat
|
|||
|
||||
<Card>
|
||||
<ParamField path="FASTMCP_SERVER_AUTH" default="Not set">
|
||||
Set to `fastmcp.server.auth.providers.discord.DiscordProvider` to use Discord authentication.
|
||||
Set to `fastmcp.server.plugins.auth.discord.provider.DiscordProvider` to use Discord authentication.
|
||||
</ParamField>
|
||||
</Card>
|
||||
|
||||
|
|
@ -233,7 +233,7 @@ HTTP request timeout for Discord API calls
|
|||
|
||||
Example `.env` file:
|
||||
```bash
|
||||
FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.discord.DiscordProvider
|
||||
FASTMCP_SERVER_AUTH=fastmcp.server.plugins.auth.discord.provider.DiscordProvider
|
||||
|
||||
FASTMCP_SERVER_AUTH_DISCORD_CLIENT_ID=12345
|
||||
FASTMCP_SERVER_AUTH_DISCORD_CLIENT_SECRET=your-client-secret
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ Create your FastMCP server using the `GitHubProvider`, which handles GitHub's OA
|
|||
|
||||
```python server.py
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.github import GitHubProvider
|
||||
from fastmcp.server.plugins.auth.github.provider import GitHubProvider
|
||||
|
||||
# The GitHubProvider handles GitHub's token format and validation
|
||||
auth_provider = GitHubProvider(
|
||||
|
|
@ -144,7 +144,7 @@ For production deployments with persistent token management across server restar
|
|||
```python server.py
|
||||
import os
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.github import GitHubProvider
|
||||
from fastmcp.server.plugins.auth.github.provider import GitHubProvider
|
||||
from key_value.aio.stores.redis import RedisStore
|
||||
from key_value.aio.wrappers.encryption import FernetEncryptionWrapper
|
||||
from cryptography.fernet import Fernet
|
||||
|
|
@ -187,7 +187,7 @@ Setting this environment variable allows the GitHub provider to be used automati
|
|||
|
||||
<Card>
|
||||
<ParamField path="FASTMCP_SERVER_AUTH" default="Not set">
|
||||
Set to `fastmcp.server.auth.providers.github.GitHubProvider` to use GitHub authentication.
|
||||
Set to `fastmcp.server.plugins.auth.github.provider.GitHubProvider` to use GitHub authentication.
|
||||
</ParamField>
|
||||
</Card>
|
||||
|
||||
|
|
@ -228,7 +228,7 @@ HTTP request timeout for GitHub API calls
|
|||
Example `.env` file:
|
||||
```bash
|
||||
# Use the GitHub provider
|
||||
FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.github.GitHubProvider
|
||||
FASTMCP_SERVER_AUTH=fastmcp.server.plugins.auth.github.provider.GitHubProvider
|
||||
|
||||
# GitHub OAuth credentials
|
||||
FASTMCP_SERVER_AUTH_GITHUB_CLIENT_ID=Ov23liAbcDefGhiJkLmN
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ Create your FastMCP server using the `GoogleProvider`, which handles Google's OA
|
|||
|
||||
```python server.py
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.google import GoogleProvider
|
||||
from fastmcp.server.plugins.auth.google.provider import GoogleProvider
|
||||
|
||||
# The GoogleProvider handles Google's token format and validation
|
||||
auth_provider = GoogleProvider(
|
||||
|
|
@ -157,7 +157,7 @@ For production deployments with persistent token management across server restar
|
|||
```python server.py
|
||||
import os
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.google import GoogleProvider
|
||||
from fastmcp.server.plugins.auth.google.provider import GoogleProvider
|
||||
from key_value.aio.stores.redis import RedisStore
|
||||
from key_value.aio.wrappers.encryption import FernetEncryptionWrapper
|
||||
from cryptography.fernet import Fernet
|
||||
|
|
@ -201,7 +201,7 @@ Setting this environment variable allows the Google provider to be used automati
|
|||
|
||||
<Card>
|
||||
<ParamField path="FASTMCP_SERVER_AUTH" default="Not set">
|
||||
Set to `fastmcp.server.auth.providers.google.GoogleProvider` to use Google authentication.
|
||||
Set to `fastmcp.server.plugins.auth.google.provider.GoogleProvider` to use Google authentication.
|
||||
</ParamField>
|
||||
</Card>
|
||||
|
||||
|
|
@ -242,7 +242,7 @@ HTTP request timeout for Google API calls
|
|||
Example `.env` file:
|
||||
```bash
|
||||
# Use the Google provider
|
||||
FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.google.GoogleProvider
|
||||
FASTMCP_SERVER_AUTH=fastmcp.server.plugins.auth.google.provider.GoogleProvider
|
||||
|
||||
# Google OAuth credentials
|
||||
FASTMCP_SERVER_AUTH_GOOGLE_CLIENT_ID=123456789.apps.googleusercontent.com
|
||||
|
|
|
|||
|
|
@ -213,7 +213,7 @@ For production deployments with persistent token management across server restar
|
|||
|
||||
import os
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.oci import OCIProvider
|
||||
from fastmcp.server.plugins.auth.oci.provider import OCIProvider
|
||||
|
||||
from key_value.aio.stores.redis import RedisStore
|
||||
from key_value.aio.wrappers.encryption import FernetEncryptionWrapper
|
||||
|
|
@ -265,7 +265,7 @@ Setting this environment variable allows the OCI provider to be used automatical
|
|||
|
||||
<Card>
|
||||
<ParamField path="FASTMCP_SERVER_AUTH" default="Not set">
|
||||
Set to `fastmcp.server.auth.providers.oci.OCIProvider` to use OCI IAM authentication.
|
||||
Set to `fastmcp.server.plugins.auth.oci.provider.OCIProvider` to use OCI IAM authentication.
|
||||
</ParamField>
|
||||
</Card>
|
||||
|
||||
|
|
@ -303,7 +303,7 @@ Redirect path configured in your OCI IAM Integrated Application
|
|||
Example `.env` file:
|
||||
```bash
|
||||
# Use the OCI IAM provider
|
||||
FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.oci.OCIProvider
|
||||
FASTMCP_SERVER_AUTH=fastmcp.server.plugins.auth.oci.provider.OCIProvider
|
||||
|
||||
# OCI IAM configuration and credentials
|
||||
FASTMCP_SERVER_AUTH_OCI_IAM_GUID=idcs-asaacasd1111.....
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ Create your FastMCP server file and use the ScalekitProvider to handle all the O
|
|||
|
||||
```python server.py
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.scalekit import ScalekitProvider
|
||||
from fastmcp.server.plugins.auth.scalekit.provider import ScalekitProvider
|
||||
|
||||
# Discovers Scalekit endpoints and set up JWT token validation
|
||||
auth_provider = ScalekitProvider(
|
||||
|
|
@ -95,7 +95,7 @@ Setting this environment variable allows the Scalekit provider to be used automa
|
|||
|
||||
<Card>
|
||||
<ParamField path="FASTMCP_SERVER_AUTH" default="Not set">
|
||||
Set to `fastmcp.server.auth.providers.scalekit.ScalekitProvider` to use Scalekit authentication.
|
||||
Set to `fastmcp.server.plugins.auth.scalekit.provider.ScalekitProvider` to use Scalekit authentication.
|
||||
</ParamField>
|
||||
</Card>
|
||||
|
||||
|
|
@ -127,7 +127,7 @@ Example `.env`:
|
|||
|
||||
```bash
|
||||
# Use the Scalekit provider
|
||||
FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.scalekit.ScalekitProvider
|
||||
FASTMCP_SERVER_AUTH=fastmcp.server.plugins.auth.scalekit.provider.ScalekitProvider
|
||||
|
||||
# Scalekit configuration
|
||||
FASTMCP_SERVER_AUTH_SCALEKITPROVIDER_ENVIRONMENT_URL=https://your-env.scalekit.com
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ Create your FastMCP server using the `SupabaseProvider`:
|
|||
|
||||
```python server.py
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.supabase import SupabaseProvider
|
||||
from fastmcp.server.plugins.auth.supabase.provider import SupabaseProvider
|
||||
|
||||
# Configure Supabase Auth
|
||||
auth = SupabaseProvider(
|
||||
|
|
@ -101,7 +101,7 @@ Setting this environment variable allows the Supabase provider to be used automa
|
|||
|
||||
<Card>
|
||||
<ParamField path="FASTMCP_SERVER_AUTH" default="Not set">
|
||||
Set to `fastmcp.server.auth.providers.supabase.SupabaseProvider` to use Supabase authentication.
|
||||
Set to `fastmcp.server.plugins.auth.supabase.provider.SupabaseProvider` to use Supabase authentication.
|
||||
</ParamField>
|
||||
</Card>
|
||||
|
||||
|
|
@ -130,7 +130,7 @@ Comma-, space-, or JSON-separated list of required OAuth scopes (e.g., `openid e
|
|||
Example `.env` file:
|
||||
```bash
|
||||
# Use the Supabase provider
|
||||
FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.supabase.SupabaseProvider
|
||||
FASTMCP_SERVER_AUTH=fastmcp.server.plugins.auth.supabase.provider.SupabaseProvider
|
||||
|
||||
# Supabase configuration
|
||||
FASTMCP_SERVER_AUTH_SUPABASE_PROJECT_URL=https://abc123.supabase.co
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ Create your FastMCP server using the `WorkOSProvider`:
|
|||
|
||||
```python server.py
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.workos import WorkOSProvider
|
||||
from fastmcp.server.plugins.auth.workos.provider import WorkOSProvider
|
||||
|
||||
# Configure WorkOS OAuth
|
||||
auth = WorkOSProvider(
|
||||
|
|
@ -135,7 +135,7 @@ For production deployments with persistent token management across server restar
|
|||
```python server.py
|
||||
import os
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.workos import WorkOSProvider
|
||||
from fastmcp.server.plugins.auth.workos.provider import WorkOSProvider
|
||||
from key_value.aio.stores.redis import RedisStore
|
||||
from key_value.aio.wrappers.encryption import FernetEncryptionWrapper
|
||||
from cryptography.fernet import Fernet
|
||||
|
|
@ -180,7 +180,7 @@ Setting this environment variable allows the WorkOS provider to be used automati
|
|||
|
||||
<Card>
|
||||
<ParamField path="FASTMCP_SERVER_AUTH" default="Not set">
|
||||
Set to `fastmcp.server.auth.providers.workos.WorkOSProvider` to use WorkOS authentication.
|
||||
Set to `fastmcp.server.plugins.auth.workos.provider.WorkOSProvider` to use WorkOS authentication.
|
||||
</ParamField>
|
||||
</Card>
|
||||
|
||||
|
|
@ -232,7 +232,7 @@ FASTMCP_SERVER_AUTH_WORKOS_BASE_URL=https://your-server.com
|
|||
FASTMCP_SERVER_AUTH_WORKOS_REQUIRED_SCOPES=["openid","profile","email"]
|
||||
|
||||
# Optional: Automatically provision WorkOS auth for all servers
|
||||
FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.workos.WorkOSProvider
|
||||
FASTMCP_SERVER_AUTH=fastmcp.server.plugins.auth.workos.provider.WorkOSProvider
|
||||
```
|
||||
|
||||
With environment variables set, you can either:
|
||||
|
|
@ -240,14 +240,14 @@ With environment variables set, you can either:
|
|||
**Option 1: Manual instantiation (env vars provide defaults)**
|
||||
```python server.py
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.workos import WorkOSProvider
|
||||
from fastmcp.server.plugins.auth.workos.provider import WorkOSProvider
|
||||
|
||||
# Env vars provide default values for WorkOSProvider()
|
||||
auth = WorkOSProvider() # Uses env var defaults
|
||||
mcp = FastMCP(name="WorkOS Protected Server", auth=auth)
|
||||
```
|
||||
|
||||
**Option 2: Automatic provisioning (requires FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.workos.WorkOSProvider)**
|
||||
**Option 2: Automatic provisioning (requires FASTMCP_SERVER_AUTH=fastmcp.server.plugins.auth.workos.provider.WorkOSProvider)**
|
||||
```python server.py
|
||||
from fastmcp import FastMCP
|
||||
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ For example, the built-in `AuthKitProvider` uses WorkOS AuthKit, which fully sup
|
|||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.workos import AuthKitProvider
|
||||
from fastmcp.server.plugins.auth.authkit.provider import AuthKitProvider
|
||||
|
||||
auth = AuthKitProvider(
|
||||
authkit_domain="https://your-project.authkit.app",
|
||||
|
|
@ -136,7 +136,7 @@ For example, the built-in `GitHubProvider` extends `OAuthProxy` to work with Git
|
|||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.github import GitHubProvider
|
||||
from fastmcp.server.plugins.auth.github.provider import GitHubProvider
|
||||
|
||||
auth = GitHubProvider(
|
||||
client_id="Ov23li...", # Your GitHub OAuth App ID
|
||||
|
|
@ -202,11 +202,11 @@ Authentication providers are configured by specifying the full module path to th
|
|||
|
||||
<ParamField path="FASTMCP_SERVER_AUTH" type="string">
|
||||
The full module path to the authentication provider class. Examples:
|
||||
- `fastmcp.server.auth.providers.github.GitHubProvider` - GitHub OAuth
|
||||
- `fastmcp.server.auth.providers.google.GoogleProvider` - Google OAuth
|
||||
- `fastmcp.server.plugins.auth.github.provider.GitHubProvider` - GitHub OAuth
|
||||
- `fastmcp.server.plugins.auth.google.provider.GoogleProvider` - Google OAuth
|
||||
- `fastmcp.server.auth.providers.jwt.JWTVerifier` - JWT token verification
|
||||
- `fastmcp.server.auth.providers.workos.WorkOSProvider` - WorkOS OAuth
|
||||
- `fastmcp.server.auth.providers.workos.AuthKitProvider` - WorkOS AuthKit
|
||||
- `fastmcp.server.plugins.auth.workos.provider.WorkOSProvider` - WorkOS OAuth
|
||||
- `fastmcp.server.plugins.auth.authkit.provider.AuthKitProvider` - WorkOS AuthKit
|
||||
- `mycompany.auth.CustomProvider` - Your custom provider class
|
||||
</ParamField>
|
||||
|
||||
|
|
@ -214,12 +214,12 @@ When using providers like GitHub or Google, you'll need to set provider-specific
|
|||
|
||||
```bash
|
||||
# GitHub OAuth
|
||||
export FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.github.GitHubProvider
|
||||
export FASTMCP_SERVER_AUTH=fastmcp.server.plugins.auth.github.provider.GitHubProvider
|
||||
export FASTMCP_SERVER_AUTH_GITHUB_CLIENT_ID="Ov23li..."
|
||||
export FASTMCP_SERVER_AUTH_GITHUB_CLIENT_SECRET="github_pat_..."
|
||||
|
||||
# Google OAuth
|
||||
export FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.google.GoogleProvider
|
||||
export FASTMCP_SERVER_AUTH=fastmcp.server.plugins.auth.google.provider.GoogleProvider
|
||||
export FASTMCP_SERVER_AUTH_GOOGLE_CLIENT_ID="123456.apps.googleusercontent.com"
|
||||
export FASTMCP_SERVER_AUTH_GOOGLE_CLIENT_SECRET="GOCSPX-..."
|
||||
```
|
||||
|
|
|
|||
|
|
@ -345,7 +345,7 @@ auth = OAuthProxy(..., client_storage=MemoryStore())
|
|||
FastMCP includes pre-configured providers for common services:
|
||||
|
||||
```python
|
||||
from fastmcp.server.auth.providers.github import GitHubProvider
|
||||
from fastmcp.server.plugins.auth.github.provider import GitHubProvider
|
||||
|
||||
auth = GitHubProvider(
|
||||
client_id="your-github-app-id",
|
||||
|
|
@ -590,7 +590,7 @@ For production deployments, configure the OAuth proxy through environment variab
|
|||
|
||||
```bash
|
||||
# Specify the provider implementation
|
||||
export FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.github.GitHubProvider
|
||||
export FASTMCP_SERVER_AUTH=fastmcp.server.plugins.auth.github.provider.GitHubProvider
|
||||
|
||||
# Provider-specific credentials
|
||||
export FASTMCP_SERVER_AUTH_GITHUB_CLIENT_ID="Ov23li..."
|
||||
|
|
|
|||
|
|
@ -218,7 +218,7 @@ auth = OIDCProxy(
|
|||
FastMCP includes pre-configured OIDC providers for common services:
|
||||
|
||||
```python
|
||||
from fastmcp.server.auth.providers.auth0 import Auth0Provider
|
||||
from fastmcp.server.plugins.auth.auth0.provider import Auth0Provider
|
||||
|
||||
auth = Auth0Provider(
|
||||
config_url="https://.../.well-known/openid-configuration",
|
||||
|
|
@ -247,7 +247,7 @@ For production deployments, configure the OIDC proxy through environment variabl
|
|||
|
||||
```bash
|
||||
# Specify the provider implementation
|
||||
export FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.auth0.Auth0Provider
|
||||
export FASTMCP_SERVER_AUTH=fastmcp.server.plugins.auth.auth0.provider.Auth0Provider
|
||||
|
||||
# Provider-specific credentials
|
||||
export FASTMCP_SERVER_AUTH_AUTH0_CONFIG_URL=https://.../.well-known/openid-configuration
|
||||
|
|
|
|||
|
|
@ -183,7 +183,7 @@ WorkOS AuthKit provides an excellent example of remote OAuth integration. The `A
|
|||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.workos import AuthKitProvider
|
||||
from fastmcp.server.plugins.auth.authkit.provider import AuthKitProvider
|
||||
|
||||
auth = AuthKitProvider(
|
||||
authkit_domain="https://your-project.authkit.app",
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ middleware = ResponseCachingMiddleware(
|
|||
Or with OAuth token storage:
|
||||
|
||||
```python
|
||||
from fastmcp.server.auth.providers.github import GitHubProvider
|
||||
from fastmcp.server.plugins.auth.github.provider import GitHubProvider
|
||||
from key_value.aio.stores.disk import DiskStore
|
||||
|
||||
auth = GitHubProvider(
|
||||
|
|
@ -110,7 +110,7 @@ For OAuth token storage:
|
|||
|
||||
```python
|
||||
import os
|
||||
from fastmcp.server.auth.providers.github import GitHubProvider
|
||||
from fastmcp.server.plugins.auth.github.provider import GitHubProvider
|
||||
from key_value.aio.stores.redis import RedisStore
|
||||
|
||||
auth = GitHubProvider(
|
||||
|
|
@ -162,7 +162,7 @@ By default, FastMCP automatically manages keys and storage based on your platfor
|
|||
No configuration needed:
|
||||
|
||||
```python
|
||||
from fastmcp.server.auth.providers.github import GitHubProvider
|
||||
from fastmcp.server.plugins.auth.github.provider import GitHubProvider
|
||||
|
||||
auth = GitHubProvider(
|
||||
client_id="your-id",
|
||||
|
|
@ -177,7 +177,7 @@ For production deployments, configure explicit keys and persistent network-acces
|
|||
|
||||
```python
|
||||
import os
|
||||
from fastmcp.server.auth.providers.github import GitHubProvider
|
||||
from fastmcp.server.plugins.auth.github.provider import GitHubProvider
|
||||
from key_value.aio.stores.redis import RedisStore
|
||||
from key_value.aio.wrappers.encryption import FernetEncryptionWrapper
|
||||
from cryptography.fernet import Fernet
|
||||
|
|
|
|||
|
|
@ -1,135 +1,20 @@
|
|||
"""Auth0 OAuth provider for FastMCP.
|
||||
"""Backward compatibility shim for Auth0 auth provider."""
|
||||
|
||||
This module provides a complete Auth0 integration that's ready to use with
|
||||
just the configuration URL, client ID, client secret, audience, and base URL.
|
||||
from __future__ import annotations
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.auth0 import Auth0Provider
|
||||
import warnings
|
||||
|
||||
# Simple Auth0 OAuth protection
|
||||
auth = Auth0Provider(
|
||||
config_url="https://auth0.config.url",
|
||||
client_id="your-auth0-client-id",
|
||||
client_secret="your-auth0-client-secret",
|
||||
audience="your-auth0-api-audience",
|
||||
base_url="http://localhost:8000",
|
||||
from fastmcp import settings
|
||||
from fastmcp.exceptions import FastMCPDeprecationWarning
|
||||
|
||||
if settings.deprecation_warnings:
|
||||
warnings.warn(
|
||||
"fastmcp.server.auth.providers.auth0 is deprecated. "
|
||||
"Import from fastmcp.server.plugins.auth.auth0.provider instead.",
|
||||
FastMCPDeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
mcp = FastMCP("My Protected Server", auth=auth)
|
||||
```
|
||||
"""
|
||||
from fastmcp.server.plugins.auth.auth0.provider import Auth0Provider
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from key_value.aio.protocols import AsyncKeyValue
|
||||
from pydantic import AnyHttpUrl
|
||||
|
||||
from fastmcp.server.auth.oidc_proxy import OIDCProxy
|
||||
from fastmcp.utilities.auth import parse_scopes
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class Auth0Provider(OIDCProxy):
|
||||
"""An Auth0 provider implementation for FastMCP.
|
||||
|
||||
This provider is a complete Auth0 integration that's ready to use with
|
||||
just the configuration URL, client ID, client secret, audience, and base URL.
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.auth0 import Auth0Provider
|
||||
|
||||
# Simple Auth0 OAuth protection
|
||||
auth = Auth0Provider(
|
||||
config_url="https://auth0.config.url",
|
||||
client_id="your-auth0-client-id",
|
||||
client_secret="your-auth0-client-secret",
|
||||
audience="your-auth0-api-audience",
|
||||
base_url="http://localhost:8000",
|
||||
)
|
||||
|
||||
mcp = FastMCP("My Protected Server", auth=auth)
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
config_url: AnyHttpUrl | str,
|
||||
client_id: str,
|
||||
client_secret: str,
|
||||
audience: str,
|
||||
base_url: AnyHttpUrl | str,
|
||||
resource_base_url: AnyHttpUrl | str | None = None,
|
||||
issuer_url: AnyHttpUrl | str | None = None,
|
||||
required_scopes: list[str] | None = None,
|
||||
redirect_path: str | None = None,
|
||||
allowed_client_redirect_uris: list[str] | None = None,
|
||||
client_storage: AsyncKeyValue | None = None,
|
||||
jwt_signing_key: str | bytes | None = None,
|
||||
require_authorization_consent: bool | Literal["remember", "external"] = True,
|
||||
consent_csp_policy: str | None = None,
|
||||
forward_resource: bool = True,
|
||||
) -> None:
|
||||
"""Initialize Auth0 OAuth provider.
|
||||
|
||||
Args:
|
||||
config_url: Auth0 config URL
|
||||
client_id: Auth0 application client id
|
||||
client_secret: Auth0 application client secret
|
||||
audience: Auth0 API audience
|
||||
base_url: Public URL where OAuth endpoints will be accessible (includes any mount path)
|
||||
resource_base_url: Optional public base URL for the protected resource metadata
|
||||
and token audience. Defaults to ``base_url``.
|
||||
issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL
|
||||
to avoid 404s during discovery when mounting under a path.
|
||||
required_scopes: Required Auth0 scopes (defaults to ["openid"])
|
||||
redirect_path: Redirect path configured in Auth0 application
|
||||
allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients.
|
||||
If None (default), all URIs are allowed. If empty list, no URIs are allowed.
|
||||
client_storage: Storage backend for OAuth state (client registrations, encrypted tokens).
|
||||
If None, an encrypted file store will be created in the data directory
|
||||
(derived from `platformdirs`).
|
||||
jwt_signing_key: Secret for signing FastMCP JWT tokens (any string or bytes). If bytes are provided,
|
||||
they will be used as is. If a string is provided, it will be derived into a 32-byte key. If not
|
||||
provided, the upstream client secret will be used to derive a 32-byte key using PBKDF2.
|
||||
require_authorization_consent: Whether to require user consent before authorizing clients (default True).
|
||||
When True, users see a consent screen before being redirected to Auth0.
|
||||
When False, authorization proceeds directly without user confirmation.
|
||||
When "external", the built-in consent screen is skipped but no warning is
|
||||
logged, indicating that consent is handled externally (e.g. by the upstream IdP).
|
||||
SECURITY WARNING: Only set to False for local development or testing environments.
|
||||
"""
|
||||
# Parse scopes if provided as string
|
||||
auth0_required_scopes = (
|
||||
parse_scopes(required_scopes) if required_scopes is not None else ["openid"]
|
||||
)
|
||||
|
||||
super().__init__(
|
||||
config_url=config_url,
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
audience=audience,
|
||||
base_url=base_url,
|
||||
resource_base_url=resource_base_url,
|
||||
issuer_url=issuer_url,
|
||||
redirect_path=redirect_path,
|
||||
required_scopes=auth0_required_scopes,
|
||||
allowed_client_redirect_uris=allowed_client_redirect_uris,
|
||||
client_storage=client_storage,
|
||||
jwt_signing_key=jwt_signing_key,
|
||||
require_authorization_consent=require_authorization_consent,
|
||||
consent_csp_policy=consent_csp_policy,
|
||||
forward_resource=forward_resource,
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"Initialized Auth0 OAuth provider for client %s with scopes: %s",
|
||||
client_id,
|
||||
auth0_required_scopes,
|
||||
)
|
||||
__all__ = ["Auth0Provider"]
|
||||
|
|
|
|||
|
|
@ -1,229 +1,23 @@
|
|||
"""AWS Cognito OAuth provider for FastMCP.
|
||||
|
||||
This module provides a complete AWS Cognito OAuth integration that's ready to use
|
||||
with a user pool ID, domain prefix, client ID and client secret. It handles all
|
||||
the complexity of AWS Cognito's OAuth flow, token validation, and user management.
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.aws_cognito import AWSCognitoProvider
|
||||
|
||||
# Simple AWS Cognito OAuth protection
|
||||
auth = AWSCognitoProvider(
|
||||
user_pool_id="your-user-pool-id",
|
||||
aws_region="eu-central-1",
|
||||
client_id="your-cognito-client-id",
|
||||
client_secret="your-cognito-client-secret"
|
||||
)
|
||||
|
||||
mcp = FastMCP("My Protected Server", auth=auth)
|
||||
```
|
||||
"""
|
||||
"""Backward compatibility shim for AWS Cognito auth provider."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
import warnings
|
||||
|
||||
from key_value.aio.protocols import AsyncKeyValue
|
||||
from pydantic import AnyHttpUrl
|
||||
from fastmcp import settings
|
||||
from fastmcp.exceptions import FastMCPDeprecationWarning
|
||||
|
||||
from fastmcp.server.auth.auth import AccessToken
|
||||
from fastmcp.server.auth.oidc_proxy import OIDCProxy
|
||||
from fastmcp.server.auth.providers.jwt import JWTVerifier
|
||||
from fastmcp.utilities.auth import parse_scopes
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
if settings.deprecation_warnings:
|
||||
warnings.warn(
|
||||
"fastmcp.server.auth.providers.aws is deprecated. "
|
||||
"Import from fastmcp.server.plugins.auth.aws.provider instead.",
|
||||
FastMCPDeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
from fastmcp.server.plugins.auth.aws.provider import (
|
||||
AWSCognitoProvider,
|
||||
AWSCognitoTokenVerifier,
|
||||
)
|
||||
|
||||
|
||||
class AWSCognitoTokenVerifier(JWTVerifier):
|
||||
"""Token verifier for Cognito access tokens.
|
||||
|
||||
Cognito access tokens use a ``client_id`` claim instead of the
|
||||
standard ``aud`` claim. This subclass passes ``audience=None``
|
||||
to the parent (skipping the ``aud`` check) and validates the
|
||||
``client_id`` claim directly.
|
||||
"""
|
||||
|
||||
def __init__(self, *, audience: str | list[str] | None = None, **kwargs):
|
||||
self._expected_client_id = audience
|
||||
super().__init__(audience=None, **kwargs)
|
||||
|
||||
async def verify_token(self, token: str) -> AccessToken | None:
|
||||
"""Verify token and filter claims to Cognito-specific subset."""
|
||||
access_token = await super().verify_token(token)
|
||||
if not access_token:
|
||||
return None
|
||||
|
||||
# Validate client_id claim (Cognito's equivalent of aud)
|
||||
if self._expected_client_id:
|
||||
token_client_id = access_token.claims.get("client_id")
|
||||
if isinstance(self._expected_client_id, list):
|
||||
valid = token_client_id in self._expected_client_id
|
||||
else:
|
||||
valid = token_client_id == self._expected_client_id
|
||||
if not valid:
|
||||
self.logger.debug(
|
||||
"Token validation failed: client_id mismatch (expected %s, got %s)",
|
||||
self._expected_client_id,
|
||||
token_client_id,
|
||||
)
|
||||
return None
|
||||
|
||||
# Filter claims to Cognito-specific subset
|
||||
cognito_claims = {
|
||||
"sub": access_token.claims.get("sub"),
|
||||
"username": access_token.claims.get("username"),
|
||||
"cognito:groups": access_token.claims.get("cognito:groups", []),
|
||||
}
|
||||
|
||||
return AccessToken(
|
||||
token=access_token.token,
|
||||
client_id=access_token.client_id,
|
||||
scopes=access_token.scopes,
|
||||
expires_at=access_token.expires_at,
|
||||
claims=cognito_claims,
|
||||
)
|
||||
|
||||
|
||||
class AWSCognitoProvider(OIDCProxy):
|
||||
"""Complete AWS Cognito OAuth provider for FastMCP.
|
||||
|
||||
This provider makes it trivial to add AWS Cognito OAuth protection to any
|
||||
FastMCP server using OIDC Discovery. Just provide your Cognito User Pool details,
|
||||
client credentials, and a base URL, and you're ready to go.
|
||||
|
||||
Features:
|
||||
- Automatic OIDC Discovery from AWS Cognito User Pool
|
||||
- Automatic JWT token validation via Cognito's public keys
|
||||
- Cognito-specific claim filtering (sub, username, cognito:groups)
|
||||
- Support for Cognito User Pools
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.aws_cognito import AWSCognitoProvider
|
||||
|
||||
auth = AWSCognitoProvider(
|
||||
user_pool_id="eu-central-1_XXXXXXXXX",
|
||||
aws_region="eu-central-1",
|
||||
client_id="your-cognito-client-id",
|
||||
client_secret="your-cognito-client-secret",
|
||||
base_url="https://my-server.com",
|
||||
redirect_path="/custom/callback",
|
||||
)
|
||||
|
||||
mcp = FastMCP("My App", auth=auth)
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
user_pool_id: str,
|
||||
client_id: str,
|
||||
client_secret: str,
|
||||
base_url: AnyHttpUrl | str,
|
||||
resource_base_url: AnyHttpUrl | str | None = None,
|
||||
aws_region: str = "eu-central-1",
|
||||
issuer_url: AnyHttpUrl | str | None = None,
|
||||
redirect_path: str = "/auth/callback",
|
||||
required_scopes: list[str] | None = None,
|
||||
allowed_client_redirect_uris: list[str] | None = None,
|
||||
client_storage: AsyncKeyValue | None = None,
|
||||
jwt_signing_key: str | bytes | None = None,
|
||||
require_authorization_consent: bool | Literal["remember", "external"] = True,
|
||||
consent_csp_policy: str | None = None,
|
||||
forward_resource: bool = True,
|
||||
):
|
||||
"""Initialize AWS Cognito OAuth provider.
|
||||
|
||||
Args:
|
||||
user_pool_id: Your Cognito User Pool ID (e.g., "eu-central-1_XXXXXXXXX")
|
||||
client_id: Cognito app client ID
|
||||
client_secret: Cognito app client secret
|
||||
base_url: Public URL where OAuth endpoints will be accessible (includes any mount path)
|
||||
resource_base_url: Optional public base URL for the protected resource metadata
|
||||
and token audience. Defaults to ``base_url``.
|
||||
aws_region: AWS region where your User Pool is located (defaults to "eu-central-1")
|
||||
issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL
|
||||
to avoid 404s during discovery when mounting under a path.
|
||||
redirect_path: Redirect path configured in Cognito app (defaults to "/auth/callback")
|
||||
required_scopes: Required Cognito scopes (defaults to ["openid"])
|
||||
allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients.
|
||||
If None (default), all URIs are allowed. If empty list, no URIs are allowed.
|
||||
client_storage: Storage backend for OAuth state (client registrations, encrypted tokens).
|
||||
If None, an encrypted file store will be created in the data directory
|
||||
(derived from `platformdirs`).
|
||||
jwt_signing_key: Secret for signing FastMCP JWT tokens (any string or bytes). If bytes are provided,
|
||||
they will be used as is. If a string is provided, it will be derived into a 32-byte key. If not
|
||||
provided, the upstream client secret will be used to derive a 32-byte key using PBKDF2.
|
||||
require_authorization_consent: Whether to require user consent before authorizing clients (default True).
|
||||
When True, users see a consent screen before being redirected to AWS Cognito.
|
||||
When False, authorization proceeds directly without user confirmation.
|
||||
When "external", the built-in consent screen is skipped but no warning is
|
||||
logged, indicating that consent is handled externally (e.g. by the upstream IdP).
|
||||
SECURITY WARNING: Only set to False for local development or testing environments.
|
||||
"""
|
||||
# Parse scopes if provided as string
|
||||
required_scopes_final = (
|
||||
parse_scopes(required_scopes) if required_scopes is not None else ["openid"]
|
||||
)
|
||||
|
||||
# Construct OIDC discovery URL
|
||||
config_url = f"https://cognito-idp.{aws_region}.amazonaws.com/{user_pool_id}/.well-known/openid-configuration"
|
||||
|
||||
# Store Cognito-specific info for claim filtering
|
||||
self.user_pool_id = user_pool_id
|
||||
self.aws_region = aws_region
|
||||
self.client_id = client_id
|
||||
|
||||
# Initialize OIDC proxy with Cognito discovery
|
||||
super().__init__(
|
||||
config_url=config_url,
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
algorithm="RS256",
|
||||
required_scopes=required_scopes_final,
|
||||
base_url=base_url,
|
||||
resource_base_url=resource_base_url,
|
||||
issuer_url=issuer_url,
|
||||
redirect_path=redirect_path,
|
||||
allowed_client_redirect_uris=allowed_client_redirect_uris,
|
||||
client_storage=client_storage,
|
||||
jwt_signing_key=jwt_signing_key,
|
||||
require_authorization_consent=require_authorization_consent,
|
||||
consent_csp_policy=consent_csp_policy,
|
||||
forward_resource=forward_resource,
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"Initialized AWS Cognito OAuth provider for client %s with scopes: %s",
|
||||
client_id,
|
||||
required_scopes_final,
|
||||
)
|
||||
|
||||
def get_token_verifier(
|
||||
self,
|
||||
*,
|
||||
algorithm: str | None = None,
|
||||
audience: str | None = None,
|
||||
required_scopes: list[str] | None = None,
|
||||
timeout_seconds: int | None = None,
|
||||
) -> AWSCognitoTokenVerifier:
|
||||
"""Creates a Cognito-specific token verifier with claim filtering.
|
||||
|
||||
Args:
|
||||
algorithm: Optional token verifier algorithm
|
||||
audience: Optional token verifier audience
|
||||
required_scopes: Optional token verifier required_scopes
|
||||
timeout_seconds: HTTP request timeout in seconds
|
||||
"""
|
||||
return AWSCognitoTokenVerifier(
|
||||
issuer=str(self.oidc_config.issuer),
|
||||
audience=audience or self.client_id,
|
||||
algorithm=algorithm,
|
||||
jwks_uri=str(self.oidc_config.jwks_uri),
|
||||
required_scopes=required_scopes,
|
||||
)
|
||||
__all__ = ["AWSCognitoProvider", "AWSCognitoTokenVerifier"]
|
||||
|
|
|
|||
|
|
@ -1,768 +1,24 @@
|
|||
"""Azure (Microsoft Entra) OAuth provider for FastMCP.
|
||||
|
||||
This provider implements Azure/Microsoft Entra ID OAuth authentication
|
||||
using the OAuth Proxy pattern for non-DCR OAuth flows.
|
||||
"""
|
||||
"""Backward compatibility shim for Azure auth provider."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from collections import OrderedDict
|
||||
from typing import TYPE_CHECKING, Any, Literal, cast
|
||||
|
||||
import httpx
|
||||
from key_value.aio.protocols import AsyncKeyValue
|
||||
|
||||
from fastmcp.dependencies import Dependency
|
||||
from fastmcp.server.auth.auth import MultiAuth
|
||||
from fastmcp.server.auth.oauth_proxy import OAuthProxy
|
||||
from fastmcp.server.auth.providers.jwt import JWTVerifier
|
||||
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
|
||||
from pydantic import AnyHttpUrl
|
||||
|
||||
from fastmcp.server.auth.auth import AuthProvider
|
||||
|
||||
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 AzureProvider(OAuthProxy):
|
||||
"""Azure (Microsoft Entra) OAuth provider for FastMCP.
|
||||
|
||||
This provider implements Azure/Microsoft Entra ID authentication using the
|
||||
OAuth Proxy pattern. It supports both organizational accounts and personal
|
||||
Microsoft accounts depending on the tenant configuration.
|
||||
|
||||
Scope Handling:
|
||||
- required_scopes: Provide unprefixed scope names (e.g., ["read", "write"])
|
||||
→ Automatically prefixed with identifier_uri during initialization
|
||||
→ Validated on all tokens and advertised to MCP clients
|
||||
- additional_authorize_scopes: Provide full format (e.g., ["User.Read"])
|
||||
→ NOT prefixed, NOT validated, NOT advertised to clients
|
||||
→ Used to request Microsoft Graph or other upstream API permissions
|
||||
|
||||
Features:
|
||||
- OAuth proxy to Azure/Microsoft identity platform
|
||||
- JWT validation using tenant issuer and JWKS
|
||||
- Supports tenant configurations: specific tenant ID, "organizations", or "consumers"
|
||||
- Custom API scopes and Microsoft Graph scopes in a single provider
|
||||
|
||||
Setup:
|
||||
1. Create an App registration in Azure Portal
|
||||
2. Configure Web platform redirect URI: http://localhost:8000/auth/callback (or your custom path)
|
||||
3. Add an Application ID URI under "Expose an API" (defaults to api://{client_id})
|
||||
4. Add custom scopes (e.g., "read", "write") under "Expose an API"
|
||||
5. Set access token version to 2 in the App manifest: "requestedAccessTokenVersion": 2
|
||||
6. Create a client secret
|
||||
7. Get Application (client) ID, Directory (tenant) ID, and client secret
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.azure import AzureProvider
|
||||
|
||||
# Standard Azure (Public Cloud)
|
||||
auth = AzureProvider(
|
||||
client_id="your-client-id",
|
||||
client_secret="your-client-secret",
|
||||
tenant_id="your-tenant-id",
|
||||
required_scopes=["read", "write"], # Unprefixed scope names
|
||||
additional_authorize_scopes=["User.Read", "Mail.Read"], # Optional Graph scopes
|
||||
base_url="http://localhost:8000",
|
||||
# identifier_uri defaults to api://{client_id}
|
||||
)
|
||||
|
||||
# Azure Government
|
||||
auth_gov = AzureProvider(
|
||||
client_id="your-client-id",
|
||||
client_secret="your-client-secret",
|
||||
tenant_id="your-tenant-id",
|
||||
required_scopes=["read", "write"],
|
||||
base_authority="login.microsoftonline.us", # Override for Azure Gov
|
||||
base_url="http://localhost:8000",
|
||||
)
|
||||
|
||||
mcp = FastMCP("My App", auth=auth)
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
client_id: str,
|
||||
client_secret: str | None = None,
|
||||
tenant_id: str,
|
||||
required_scopes: list[str],
|
||||
base_url: str,
|
||||
resource_base_url: AnyHttpUrl | str | None = None,
|
||||
identifier_uri: str | None = None,
|
||||
issuer_url: str | None = None,
|
||||
redirect_path: str | None = None,
|
||||
additional_authorize_scopes: list[str] | None = None,
|
||||
allowed_client_redirect_uris: list[str] | None = None,
|
||||
client_storage: AsyncKeyValue | None = None,
|
||||
jwt_signing_key: str | bytes | None = None,
|
||||
require_authorization_consent: bool | Literal["remember", "external"] = True,
|
||||
consent_csp_policy: str | None = None,
|
||||
forward_resource: bool = True,
|
||||
base_authority: str = "login.microsoftonline.com",
|
||||
http_client: httpx.AsyncClient | None = None,
|
||||
enable_cimd: bool = True,
|
||||
) -> None:
|
||||
"""Initialize Azure OAuth provider.
|
||||
|
||||
Args:
|
||||
client_id: Azure application (client) ID from your App registration
|
||||
client_secret: Azure client secret from your App registration. Optional when
|
||||
using alternative credentials (e.g., managed identity with a custom
|
||||
_create_upstream_oauth_client override). When omitted, jwt_signing_key
|
||||
must be provided.
|
||||
tenant_id: Azure tenant ID (specific tenant GUID, "organizations", or "consumers")
|
||||
identifier_uri: Optional Application ID URI for your custom API (defaults to api://{client_id}).
|
||||
This URI is automatically prefixed to all required_scopes during initialization.
|
||||
Example: identifier_uri="api://my-api" + required_scopes=["read"]
|
||||
→ tokens validated for "api://my-api/read"
|
||||
base_url: Public URL where OAuth endpoints will be accessible (includes any mount path)
|
||||
resource_base_url: Optional public base URL for the protected resource metadata
|
||||
and token audience. Defaults to ``base_url``.
|
||||
issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL
|
||||
to avoid 404s during discovery when mounting under a path.
|
||||
redirect_path: Redirect path configured in Azure App registration (defaults to "/auth/callback")
|
||||
base_authority: Azure authority base URL (defaults to "login.microsoftonline.com").
|
||||
For Azure Government, use "login.microsoftonline.us".
|
||||
required_scopes: Custom API scope names WITHOUT prefix (e.g., ["read", "write"]).
|
||||
- Automatically prefixed with identifier_uri during initialization
|
||||
- Validated on all tokens
|
||||
- Advertised in Protected Resource Metadata
|
||||
- Must match scope names defined in Azure Portal under "Expose an API"
|
||||
Example: ["read", "write"] → validates tokens containing ["api://xxx/read", "api://xxx/write"]
|
||||
additional_authorize_scopes: Microsoft Graph or other upstream scopes in full format.
|
||||
- NOT prefixed with identifier_uri
|
||||
- NOT validated on tokens
|
||||
- NOT advertised to MCP clients
|
||||
- Used to request additional permissions from Azure (e.g., Graph API access)
|
||||
Example: ["User.Read", "Mail.Read"]
|
||||
These scopes allow your FastMCP server to call Microsoft Graph APIs using the
|
||||
upstream Azure token, but MCP clients are unaware of them.
|
||||
Note: "offline_access" is automatically included to obtain refresh tokens.
|
||||
allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients.
|
||||
If None (default), all URIs are allowed. If empty list, no URIs are allowed.
|
||||
client_storage: Storage backend for OAuth state (client registrations, encrypted tokens).
|
||||
If None, an encrypted file store will be created in the data directory
|
||||
(derived from `platformdirs`).
|
||||
jwt_signing_key: Secret for signing FastMCP JWT tokens (any string or bytes). If bytes are provided,
|
||||
they will be used as is. If a string is provided, it will be derived into a 32-byte key. If not
|
||||
provided, the upstream client secret will be used to derive a 32-byte key using PBKDF2.
|
||||
require_authorization_consent: Whether to require user consent before authorizing clients (default True).
|
||||
When True, users see a consent screen before being redirected to Azure.
|
||||
When False, authorization proceeds directly without user confirmation.
|
||||
When "external", the built-in consent screen is skipped but no warning is
|
||||
logged, indicating that consent is handled externally (e.g. by the upstream IdP).
|
||||
SECURITY WARNING: Only set to False for local development or testing environments.
|
||||
http_client: Optional httpx.AsyncClient for connection pooling in JWKS fetches.
|
||||
When provided, the client is reused for JWT key fetches and the caller
|
||||
is responsible for its lifecycle. When None (default), a fresh client is created per fetch.
|
||||
enable_cimd: Enable CIMD (Client ID Metadata Document) support for URL-based
|
||||
client IDs (default True). Set to False to disable.
|
||||
"""
|
||||
# Parse scopes if provided as string
|
||||
parsed_required_scopes = parse_scopes(required_scopes)
|
||||
parsed_additional_scopes: list[str] = (
|
||||
parse_scopes(additional_authorize_scopes) or []
|
||||
if additional_authorize_scopes
|
||||
else []
|
||||
)
|
||||
|
||||
# Always include offline_access to get refresh tokens from Azure
|
||||
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
|
||||
|
||||
# Cache of OBO credentials keyed by hash of user assertion token.
|
||||
# Reusing credentials allows the Azure SDK's internal token cache
|
||||
# to avoid redundant OBO exchanges for the same user + scopes.
|
||||
self._obo_credentials: OrderedDict[str, OnBehalfOfCredential] = OrderedDict()
|
||||
self._obo_max_credentials: int = 128
|
||||
|
||||
# Apply defaults
|
||||
self.identifier_uri = identifier_uri or f"api://{client_id}"
|
||||
self.additional_authorize_scopes: list[str] = parsed_additional_scopes
|
||||
|
||||
# Always validate tokens against the app's API client ID using JWT
|
||||
issuer = f"https://{base_authority}/{tenant_id}/v2.0"
|
||||
jwks_uri = f"https://{base_authority}/{tenant_id}/discovery/v2.0/keys"
|
||||
|
||||
# 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 = [
|
||||
s for s in (parsed_required_scopes or []) if s not in OIDC_SCOPES
|
||||
]
|
||||
if not validation_scopes:
|
||||
raise ValueError(
|
||||
"AzureProvider requires at least one non-OIDC scope in "
|
||||
"required_scopes (e.g., 'read', 'write'). OIDC scopes like "
|
||||
"'openid', 'profile', 'email', and 'offline_access' are not "
|
||||
"included in Azure access token claims and cannot be used for "
|
||||
"scope enforcement."
|
||||
)
|
||||
|
||||
token_verifier = JWTVerifier(
|
||||
jwks_uri=jwks_uri,
|
||||
issuer=issuer,
|
||||
audience=[client_id, self.identifier_uri],
|
||||
algorithm="RS256",
|
||||
required_scopes=validation_scopes, # Only validate non-OIDC scopes
|
||||
http_client=http_client,
|
||||
)
|
||||
|
||||
# Build Azure OAuth endpoints with tenant
|
||||
authorization_endpoint = (
|
||||
f"https://{base_authority}/{tenant_id}/oauth2/v2.0/authorize"
|
||||
)
|
||||
token_endpoint = f"https://{base_authority}/{tenant_id}/oauth2/v2.0/token"
|
||||
|
||||
# Initialize OAuth proxy with Azure endpoints
|
||||
# Remember there's hooks called, such as _prepare_scopes_for_token_exchange
|
||||
# and _prepare_scopes_for_upstream_refresh
|
||||
super().__init__(
|
||||
upstream_authorization_endpoint=authorization_endpoint,
|
||||
upstream_token_endpoint=token_endpoint,
|
||||
upstream_client_id=client_id,
|
||||
upstream_client_secret=client_secret,
|
||||
token_verifier=token_verifier,
|
||||
base_url=base_url,
|
||||
resource_base_url=resource_base_url,
|
||||
redirect_path=redirect_path,
|
||||
issuer_url=issuer_url or base_url, # Default to base_url if not specified
|
||||
allowed_client_redirect_uris=allowed_client_redirect_uris,
|
||||
client_storage=client_storage,
|
||||
jwt_signing_key=jwt_signing_key,
|
||||
require_authorization_consent=require_authorization_consent,
|
||||
consent_csp_policy=consent_csp_policy,
|
||||
forward_resource=forward_resource,
|
||||
valid_scopes=parsed_required_scopes,
|
||||
enable_cimd=enable_cimd,
|
||||
)
|
||||
|
||||
authority_info = ""
|
||||
if base_authority != "login.microsoftonline.com":
|
||||
authority_info = f" using authority {base_authority}"
|
||||
logger.info(
|
||||
"Initialized Azure OAuth provider for client %s with tenant %s%s%s",
|
||||
client_id,
|
||||
tenant_id,
|
||||
f" and identifier_uri {self.identifier_uri}" if self.identifier_uri else "",
|
||||
authority_info,
|
||||
)
|
||||
|
||||
async def authorize(
|
||||
self,
|
||||
client: OAuthClientInformationFull,
|
||||
params: AuthorizationParams,
|
||||
) -> str:
|
||||
"""Start OAuth transaction and redirect to Azure AD.
|
||||
|
||||
Override parent's authorize method to filter out the 'resource' parameter
|
||||
which is not supported by Azure AD v2.0 endpoints. The v2.0 endpoints use
|
||||
scopes to determine the resource/audience instead of a separate parameter.
|
||||
|
||||
Args:
|
||||
client: OAuth client information
|
||||
params: Authorization parameters from the client
|
||||
|
||||
Returns:
|
||||
Authorization URL to redirect the user to Azure AD
|
||||
"""
|
||||
# Clear the resource parameter that Azure AD v2.0 doesn't support
|
||||
# This parameter comes from RFC 8707 (OAuth 2.0 Resource Indicators)
|
||||
# but Azure AD v2.0 uses scopes instead to determine the audience
|
||||
params_to_use = params
|
||||
if hasattr(params, "resource"):
|
||||
original_resource = getattr(params, "resource", None)
|
||||
if original_resource is not None:
|
||||
params_to_use = params.model_copy(update={"resource": None})
|
||||
if original_resource:
|
||||
logger.debug(
|
||||
"Filtering out 'resource' parameter '%s' for Azure AD v2.0 (use scopes instead)",
|
||||
original_resource,
|
||||
)
|
||||
# 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 _prefix_scopes_for_azure(self, scopes: list[str]) -> list[str]:
|
||||
"""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
|
||||
|
||||
Returns:
|
||||
List of scopes with identifier_uri prefix applied where needed
|
||||
"""
|
||||
prefixed = []
|
||||
for scope in scopes:
|
||||
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 custom API scope - prefix with identifier_uri
|
||||
prefixed.append(f"{self.identifier_uri}/{scope}")
|
||||
return prefixed
|
||||
|
||||
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 = self._prefix_scopes_for_azure(unprefixed_scopes)
|
||||
|
||||
# Add Microsoft Graph scopes (not validated, not prefixed)
|
||||
if self.additional_authorize_scopes:
|
||||
prefixed_scopes.extend(self.additional_authorize_scopes)
|
||||
|
||||
# Temporarily modify transaction dict for parent's URL building
|
||||
modified_transaction = transaction.copy()
|
||||
modified_transaction["scopes"] = prefixed_scopes
|
||||
|
||||
# Let parent build the URL with prefixed scopes
|
||||
return super()._build_upstream_authorize_url(txn_id, modified_transaction)
|
||||
|
||||
def _prepare_scopes_for_token_exchange(self, scopes: list[str]) -> list[str]:
|
||||
"""Prepare scopes for Azure authorization code exchange.
|
||||
|
||||
Azure requires scopes during token exchange (AADSTS28003 error if missing).
|
||||
Azure only allows ONE resource per token request (AADSTS28000), so we only
|
||||
include scopes for this API plus OIDC scopes.
|
||||
|
||||
Args:
|
||||
scopes: Scopes from the authorization request (unprefixed)
|
||||
|
||||
Returns:
|
||||
List of scopes for Azure token endpoint
|
||||
"""
|
||||
# Prefix scopes for this API
|
||||
prefixed_scopes = self._prefix_scopes_for_azure(scopes or [])
|
||||
|
||||
# Add OIDC scopes only (not other API scopes) to avoid AADSTS28000
|
||||
if self.additional_authorize_scopes:
|
||||
prefixed_scopes.extend(
|
||||
s for s in self.additional_authorize_scopes if s in OIDC_SCOPES
|
||||
)
|
||||
|
||||
deduplicated = list(dict.fromkeys(prefixed_scopes))
|
||||
logger.debug("Token exchange scopes: %s", deduplicated)
|
||||
return deduplicated
|
||||
|
||||
def _prepare_scopes_for_upstream_refresh(self, scopes: list[str]) -> list[str]:
|
||||
"""Prepare scopes for Azure token refresh.
|
||||
|
||||
Azure requires fully-qualified scopes and only allows ONE resource per
|
||||
token request (AADSTS28000). We include scopes for this API plus OIDC scopes.
|
||||
|
||||
Args:
|
||||
scopes: Base scopes from RefreshToken (unprefixed, e.g., ["read"])
|
||||
|
||||
Returns:
|
||||
Deduplicated list of scopes formatted for Azure token endpoint
|
||||
"""
|
||||
logger.debug("Base scopes from storage: %s", scopes)
|
||||
|
||||
# Filter out any additional_authorize_scopes that may have been stored
|
||||
additional_scopes_set = set(self.additional_authorize_scopes or [])
|
||||
base_scopes = [s for s in scopes if s not in additional_scopes_set]
|
||||
|
||||
# Prefix base scopes with identifier_uri for Azure
|
||||
prefixed_scopes = self._prefix_scopes_for_azure(base_scopes)
|
||||
|
||||
# Add OIDC scopes only (not other API scopes) to avoid AADSTS28000
|
||||
if self.additional_authorize_scopes:
|
||||
prefixed_scopes.extend(
|
||||
s for s in self.additional_authorize_scopes if s in OIDC_SCOPES
|
||||
)
|
||||
|
||||
deduplicated_scopes = list(dict.fromkeys(prefixed_scopes))
|
||||
logger.debug("Scopes for Azure token endpoint: %s", deduplicated_scopes)
|
||||
return deduplicated_scopes
|
||||
|
||||
async def _extract_upstream_claims(
|
||||
self, idp_tokens: dict[str, Any]
|
||||
) -> dict[str, Any] | None:
|
||||
"""Extract claims from Azure token response to embed in FastMCP JWT.
|
||||
|
||||
Decodes the Azure access token (which is a JWT) to extract user identity
|
||||
claims. This allows gateways to inspect upstream identity information by
|
||||
decoding the FastMCP JWT without needing server-side storage lookups.
|
||||
|
||||
Azure access tokens contain claims like:
|
||||
- sub: Subject identifier (unique per user per application)
|
||||
- oid: Object ID (unique user identifier across Azure AD)
|
||||
- tid: Tenant ID
|
||||
- azp: Authorized party (client ID that requested the token)
|
||||
- name: Display name
|
||||
- given_name: First name
|
||||
- family_name: Last name
|
||||
- preferred_username: User principal name (email format)
|
||||
- upn: User Principal Name
|
||||
- email: Email address (if available)
|
||||
- roles: Application roles assigned to the user
|
||||
- groups: Group memberships (if configured)
|
||||
|
||||
Args:
|
||||
idp_tokens: Full token response from Azure, containing access_token
|
||||
and potentially id_token.
|
||||
|
||||
Returns:
|
||||
Dict of extracted claims, or None if extraction fails.
|
||||
"""
|
||||
access_token = idp_tokens.get("access_token")
|
||||
if not access_token:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Azure access tokens are JWTs - decode without verification
|
||||
# (already validated by token_verifier during token exchange)
|
||||
payload = decode_jwt_payload(access_token)
|
||||
|
||||
# Extract useful identity claims
|
||||
claims: dict[str, Any] = {}
|
||||
claim_keys = [
|
||||
"sub",
|
||||
"oid",
|
||||
"tid",
|
||||
"azp",
|
||||
"name",
|
||||
"given_name",
|
||||
"family_name",
|
||||
"preferred_username",
|
||||
"upn",
|
||||
"email",
|
||||
"roles",
|
||||
"groups",
|
||||
]
|
||||
for claim in claim_keys:
|
||||
if claim in payload:
|
||||
claims[claim] = payload[claim]
|
||||
|
||||
if claims:
|
||||
logger.debug(
|
||||
"Extracted %d Azure claims for embedding in FastMCP JWT",
|
||||
len(claims),
|
||||
)
|
||||
return claims
|
||||
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.debug("Failed to extract Azure claims: %s", e)
|
||||
return None
|
||||
|
||||
async def get_obo_credential(self, user_assertion: str) -> OnBehalfOfCredential:
|
||||
"""Get a cached or new OnBehalfOfCredential for OBO token exchange.
|
||||
|
||||
Credentials are cached by user assertion so the Azure SDK's internal
|
||||
token cache can avoid redundant OBO exchanges when the same user
|
||||
calls multiple tools with the same scopes.
|
||||
|
||||
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
|
||||
|
||||
key = hashlib.sha256(user_assertion.encode()).hexdigest()
|
||||
|
||||
if key in self._obo_credentials:
|
||||
self._obo_credentials.move_to_end(key)
|
||||
return self._obo_credentials[key]
|
||||
|
||||
obo_kwargs: dict[str, Any] = {
|
||||
"tenant_id": self._tenant_id,
|
||||
"client_id": self._upstream_client_id,
|
||||
"user_assertion": user_assertion,
|
||||
"authority": f"https://{self._base_authority}",
|
||||
}
|
||||
if self._upstream_client_secret is not None:
|
||||
obo_kwargs["client_secret"] = (
|
||||
self._upstream_client_secret.get_secret_value()
|
||||
)
|
||||
else:
|
||||
raise ValueError(
|
||||
"OBO token exchange requires either a client_secret or a subclass "
|
||||
"that overrides get_obo_credential() to provide alternative credentials "
|
||||
"(e.g., client_assertion_func for managed identity)."
|
||||
)
|
||||
credential = OnBehalfOfCredential(**obo_kwargs)
|
||||
self._obo_credentials[key] = credential
|
||||
|
||||
# Evict oldest if over capacity
|
||||
while len(self._obo_credentials) > self._obo_max_credentials:
|
||||
_, evicted = self._obo_credentials.popitem(last=False)
|
||||
await evicted.close()
|
||||
|
||||
return credential
|
||||
|
||||
async def close_obo_credentials(self) -> None:
|
||||
"""Close all cached OBO credentials."""
|
||||
credentials = list(self._obo_credentials.values())
|
||||
self._obo_credentials.clear()
|
||||
for credential in credentials:
|
||||
try:
|
||||
await credential.close()
|
||||
except Exception:
|
||||
logger.debug("Error closing OBO credential", exc_info=True)
|
||||
|
||||
|
||||
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, self._identifier_uri],
|
||||
algorithm="RS256",
|
||||
required_scopes=required_scopes,
|
||||
)
|
||||
|
||||
@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
|
||||
|
||||
|
||||
# --- Dependency injection support ---
|
||||
# These require fastmcp[azure] extra for azure-identity
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def _find_azure_provider(auth: AuthProvider | None) -> AzureProvider | None:
|
||||
"""Extract an AzureProvider from an auth provider, unwrapping MultiAuth if needed."""
|
||||
if isinstance(auth, AzureProvider):
|
||||
return auth
|
||||
|
||||
if isinstance(auth, MultiAuth) and isinstance(auth.server, AzureProvider):
|
||||
return auth.server
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class _EntraOBOToken(Dependency[str]):
|
||||
"""Dependency that performs OBO token exchange for Microsoft Entra.
|
||||
|
||||
Uses azure.identity's OnBehalfOfCredential for async-native OBO,
|
||||
with automatic token caching and refresh. Credentials are cached on
|
||||
the AzureProvider so repeated tool calls reuse existing credentials
|
||||
and benefit from the Azure SDK's internal token cache.
|
||||
"""
|
||||
|
||||
def __init__(self, scopes: list[str]):
|
||||
self.scopes = scopes
|
||||
|
||||
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()
|
||||
azure_provider = _find_azure_provider(server.auth)
|
||||
if azure_provider is None:
|
||||
raise RuntimeError(
|
||||
"EntraOBOToken requires an AzureProvider as the auth provider. "
|
||||
f"Current provider: {type(server.auth).__name__}"
|
||||
)
|
||||
|
||||
credential = await azure_provider.get_obo_credential(
|
||||
user_assertion=access_token.token,
|
||||
)
|
||||
|
||||
result = await credential.get_token(*self.scopes)
|
||||
return result.token
|
||||
|
||||
|
||||
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))
|
||||
import warnings
|
||||
|
||||
from fastmcp import settings
|
||||
from fastmcp.exceptions import FastMCPDeprecationWarning
|
||||
|
||||
if settings.deprecation_warnings:
|
||||
warnings.warn(
|
||||
"fastmcp.server.auth.providers.azure is deprecated. "
|
||||
"Import from fastmcp.server.plugins.auth.azure.provider instead.",
|
||||
FastMCPDeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
from fastmcp.server.plugins.auth.azure.provider import (
|
||||
AzureJWTVerifier,
|
||||
AzureProvider,
|
||||
EntraOBOToken,
|
||||
)
|
||||
|
||||
__all__ = ["AzureJWTVerifier", "AzureProvider", "EntraOBOToken"]
|
||||
|
|
|
|||
|
|
@ -1,388 +1,23 @@
|
|||
"""Clerk OAuth provider for FastMCP.
|
||||
|
||||
This module provides a complete Clerk OAuth integration that's ready to use
|
||||
with a Clerk domain, client ID, and client secret. It handles all the complexity
|
||||
of Clerk's OAuth/OIDC flow, token validation, and user management.
|
||||
|
||||
Clerk uses standard OIDC endpoints derived from the instance domain
|
||||
(e.g., ``https://<instance>.clerk.accounts.dev``). Token verification is
|
||||
performed via the introspection endpoint (RFC 7662) for security-critical
|
||||
checks (active status, audience, scopes), followed by the userinfo endpoint
|
||||
for profile enrichment. Userinfo failure is non-fatal.
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.clerk import ClerkProvider
|
||||
|
||||
auth = ClerkProvider(
|
||||
domain="saving-primate-16.clerk.accounts.dev",
|
||||
client_id="your-clerk-client-id",
|
||||
client_secret="your-clerk-client-secret",
|
||||
base_url="https://my-server.com",
|
||||
)
|
||||
|
||||
mcp = FastMCP("My Protected Server", auth=auth)
|
||||
```
|
||||
"""
|
||||
"""Backward compatibility shim for Clerk auth provider."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
from typing import Literal
|
||||
import warnings
|
||||
|
||||
import httpx
|
||||
from key_value.aio.protocols import AsyncKeyValue
|
||||
from pydantic import AnyHttpUrl
|
||||
from fastmcp import settings
|
||||
from fastmcp.exceptions import FastMCPDeprecationWarning
|
||||
|
||||
from fastmcp.server.auth import TokenVerifier
|
||||
from fastmcp.server.auth.auth import AccessToken
|
||||
from fastmcp.server.auth.oauth_proxy import OAuthProxy
|
||||
from fastmcp.utilities.auth import parse_scopes
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
if settings.deprecation_warnings:
|
||||
warnings.warn(
|
||||
"fastmcp.server.auth.providers.clerk is deprecated. "
|
||||
"Import from fastmcp.server.plugins.auth.clerk.provider instead.",
|
||||
FastMCPDeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
from fastmcp.server.plugins.auth.clerk.provider import (
|
||||
ClerkProvider,
|
||||
ClerkTokenVerifier,
|
||||
)
|
||||
|
||||
|
||||
class ClerkTokenVerifier(TokenVerifier):
|
||||
"""Token verifier for Clerk OAuth tokens.
|
||||
|
||||
Clerk issues standard OIDC tokens. Verification uses the introspection
|
||||
endpoint (RFC 7662) as the primary security gate — it confirms the token
|
||||
is active and provides metadata (scopes, expiry, audience). The userinfo
|
||||
endpoint is called second for profile enrichment (name, email, picture)
|
||||
and its failure is non-fatal.
|
||||
|
||||
When a ``client_id`` is configured, the audience from introspection is
|
||||
validated against it. When ``required_scopes`` are configured,
|
||||
introspection must return the token's scopes — the verifier will not
|
||||
assume scopes when introspection is unavailable.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
domain: str,
|
||||
client_id: str | None = None,
|
||||
client_secret: str | None = None,
|
||||
required_scopes: list[str] | None = None,
|
||||
timeout_seconds: int = 10,
|
||||
http_client: httpx.AsyncClient | None = None,
|
||||
):
|
||||
"""Initialize the Clerk token verifier.
|
||||
|
||||
Args:
|
||||
domain: Clerk instance domain (e.g., "saving-primate-16.clerk.accounts.dev")
|
||||
client_id: Clerk OAuth client ID, used for introspection endpoint authentication
|
||||
client_secret: Clerk OAuth client secret, used for introspection endpoint authentication
|
||||
required_scopes: Required OAuth scopes (e.g., ["openid", "email", "profile"])
|
||||
timeout_seconds: HTTP request timeout
|
||||
http_client: Optional httpx.AsyncClient for connection pooling. When provided,
|
||||
the client is reused across calls and the caller is responsible for its
|
||||
lifecycle. When None (default), a fresh client is created per call.
|
||||
"""
|
||||
super().__init__(required_scopes=required_scopes)
|
||||
self.domain = domain.rstrip("/")
|
||||
self._client_id = client_id
|
||||
self._client_secret = client_secret
|
||||
self.timeout_seconds = timeout_seconds
|
||||
self._http_client = http_client
|
||||
|
||||
self._userinfo_url = f"https://{self.domain}/oauth/userinfo"
|
||||
self._introspection_url = f"https://{self.domain}/oauth/token_info"
|
||||
|
||||
async def verify_token(self, token: str) -> AccessToken | None:
|
||||
"""Verify a Clerk OAuth token via introspection and userinfo.
|
||||
|
||||
Calls the introspection endpoint first to validate the token and
|
||||
retrieve auth metadata (active status, scopes, expiry, audience).
|
||||
If the token passes security checks, the userinfo endpoint is called
|
||||
for profile enrichment. Userinfo failure is non-fatal.
|
||||
|
||||
When a ``client_id`` is configured, the token's audience must match it.
|
||||
When ``required_scopes`` are configured, introspection must confirm
|
||||
them; tokens are rejected if scope information is unavailable.
|
||||
"""
|
||||
try:
|
||||
async with (
|
||||
contextlib.nullcontext(self._http_client)
|
||||
if self._http_client is not None
|
||||
else httpx.AsyncClient(timeout=self.timeout_seconds)
|
||||
) as client:
|
||||
# Step 1: Validate token via introspection (RFC 7662).
|
||||
# Security-critical checks (active, audience, scopes) come first.
|
||||
introspect_data_payload: dict = {"token": token}
|
||||
introspect_kwargs: dict = {
|
||||
"data": introspect_data_payload,
|
||||
"headers": {"User-Agent": "FastMCP-Clerk-OAuth"},
|
||||
}
|
||||
|
||||
if self._client_id and self._client_secret:
|
||||
introspect_kwargs["auth"] = (
|
||||
self._client_id,
|
||||
self._client_secret,
|
||||
)
|
||||
elif self._client_id:
|
||||
introspect_data_payload["client_id"] = self._client_id
|
||||
|
||||
introspect_response = await client.post(
|
||||
self._introspection_url,
|
||||
**introspect_kwargs,
|
||||
)
|
||||
|
||||
if introspect_response.status_code != 200:
|
||||
logger.debug(
|
||||
"Clerk introspection failed: %d",
|
||||
introspect_response.status_code,
|
||||
)
|
||||
return None
|
||||
|
||||
introspect_data = introspect_response.json()
|
||||
|
||||
# RFC 7662 requires the 'active' field in the response.
|
||||
# A missing field indicates a malformed response — reject.
|
||||
if "active" not in introspect_data or not introspect_data["active"]:
|
||||
logger.debug(
|
||||
"Clerk introspection: token inactive or missing 'active' field"
|
||||
)
|
||||
return None
|
||||
|
||||
scope_str = introspect_data.get("scope", "")
|
||||
token_scopes = scope_str.split() if scope_str else []
|
||||
|
||||
aud = introspect_data.get("aud") or introspect_data.get("client_id")
|
||||
|
||||
expires_at: int | None = None
|
||||
exp = introspect_data.get("exp")
|
||||
if exp is not None:
|
||||
with contextlib.suppress(ValueError, TypeError):
|
||||
expires_at = int(exp)
|
||||
|
||||
if self._client_id and aud != self._client_id:
|
||||
logger.debug(
|
||||
"Clerk token audience mismatch: got %s, expected %s",
|
||||
aud,
|
||||
self._client_id,
|
||||
)
|
||||
return None
|
||||
|
||||
if self.required_scopes:
|
||||
if not token_scopes:
|
||||
logger.debug(
|
||||
"Clerk token missing scope information; "
|
||||
"cannot verify required scopes %s",
|
||||
self.required_scopes,
|
||||
)
|
||||
return None
|
||||
token_scopes_set = set(token_scopes)
|
||||
required_scopes_set = set(self.required_scopes)
|
||||
if not required_scopes_set.issubset(token_scopes_set):
|
||||
logger.debug(
|
||||
"Clerk token missing required scopes. Has %s, needs %s",
|
||||
token_scopes_set,
|
||||
required_scopes_set,
|
||||
)
|
||||
return None
|
||||
|
||||
# Step 2: Fetch user profile via userinfo.
|
||||
# Enriches the token with profile data (name, email, picture).
|
||||
sub = introspect_data.get("sub")
|
||||
user_data: dict = {}
|
||||
try:
|
||||
userinfo_response = await client.get(
|
||||
self._userinfo_url,
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"User-Agent": "FastMCP-Clerk-OAuth",
|
||||
},
|
||||
)
|
||||
if userinfo_response.status_code == 200:
|
||||
user_data = userinfo_response.json()
|
||||
if not sub:
|
||||
sub = user_data.get("sub")
|
||||
except Exception as e:
|
||||
logger.debug("Clerk userinfo call failed: %s", e)
|
||||
|
||||
if not sub:
|
||||
logger.debug("Clerk token missing 'sub' claim")
|
||||
return None
|
||||
|
||||
access_token = AccessToken(
|
||||
token=token,
|
||||
client_id=aud or sub,
|
||||
scopes=token_scopes,
|
||||
expires_at=expires_at,
|
||||
claims={
|
||||
"sub": sub,
|
||||
"aud": aud,
|
||||
"email": user_data.get("email"),
|
||||
"email_verified": user_data.get("email_verified"),
|
||||
"name": user_data.get("name"),
|
||||
"picture": user_data.get("picture"),
|
||||
"given_name": user_data.get("given_name"),
|
||||
"family_name": user_data.get("family_name"),
|
||||
"preferred_username": user_data.get("preferred_username"),
|
||||
"iss": user_data.get("iss"),
|
||||
"clerk_user_data": user_data or None,
|
||||
},
|
||||
)
|
||||
logger.debug("Clerk token verified successfully for sub=%s", sub)
|
||||
return access_token
|
||||
|
||||
except httpx.RequestError as e:
|
||||
logger.debug("Failed to verify Clerk token: %s", e)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.debug("Clerk token verification error: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
class ClerkProvider(OAuthProxy):
|
||||
"""Complete Clerk OAuth provider for FastMCP.
|
||||
|
||||
This provider makes it trivial to add Clerk OAuth protection to any
|
||||
FastMCP server. Provide your Clerk instance domain, OAuth app credentials,
|
||||
and a base URL, and you're ready to go.
|
||||
|
||||
Clerk uses standard OIDC endpoints derived from the instance domain.
|
||||
All endpoint URLs are constructed automatically from the domain parameter.
|
||||
|
||||
Features:
|
||||
- Transparent OAuth proxy to Clerk
|
||||
- Automatic token validation via Clerk's userinfo & introspection APIs
|
||||
- User information extraction from Clerk's OIDC claims
|
||||
- PKCE support (S256)
|
||||
- Minimal configuration required
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.clerk import ClerkProvider
|
||||
|
||||
auth = ClerkProvider(
|
||||
domain="saving-primate-16.clerk.accounts.dev",
|
||||
client_id="your-clerk-client-id",
|
||||
client_secret="your-clerk-client-secret",
|
||||
base_url="https://my-server.com",
|
||||
)
|
||||
|
||||
mcp = FastMCP("My App", auth=auth)
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
domain: str,
|
||||
client_id: str,
|
||||
client_secret: str | None = None,
|
||||
base_url: AnyHttpUrl | str,
|
||||
resource_base_url: AnyHttpUrl | str | None = None,
|
||||
issuer_url: AnyHttpUrl | str | None = None,
|
||||
redirect_path: str | None = None,
|
||||
required_scopes: list[str] | None = None,
|
||||
valid_scopes: list[str] | None = None,
|
||||
timeout_seconds: int = 10,
|
||||
allowed_client_redirect_uris: list[str] | None = None,
|
||||
client_storage: AsyncKeyValue | None = None,
|
||||
jwt_signing_key: str | bytes | None = None,
|
||||
require_authorization_consent: bool | Literal["remember", "external"] = True,
|
||||
consent_csp_policy: str | None = None,
|
||||
forward_resource: bool = True,
|
||||
extra_authorize_params: dict[str, str] | None = None,
|
||||
http_client: httpx.AsyncClient | None = None,
|
||||
enable_cimd: bool = True,
|
||||
):
|
||||
"""Initialize Clerk OAuth provider.
|
||||
|
||||
Args:
|
||||
domain: Clerk instance domain (e.g., "saving-primate-16.clerk.accounts.dev").
|
||||
This is used to derive all OAuth/OIDC endpoint URLs.
|
||||
client_id: Clerk OAuth application client ID
|
||||
client_secret: Clerk OAuth application client secret.
|
||||
Optional for PKCE public clients. When omitted, jwt_signing_key must be provided.
|
||||
base_url: Public URL where OAuth endpoints will be accessible (includes any mount path)
|
||||
resource_base_url: Optional public base URL for the protected resource metadata
|
||||
and token audience. Defaults to ``base_url``.
|
||||
issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL
|
||||
to avoid 404s during discovery when mounting under a path.
|
||||
redirect_path: Redirect path configured in Clerk OAuth app (defaults to "/auth/callback")
|
||||
required_scopes: Required Clerk scopes (defaults to ["openid", "email", "profile"]).
|
||||
Clerk supports: "openid", "email", "profile", "public_metadata",
|
||||
"private_metadata", "offline_access".
|
||||
valid_scopes: All scopes that clients are allowed to request, advertised through
|
||||
well-known endpoints. Defaults to required_scopes if not provided.
|
||||
timeout_seconds: HTTP request timeout for Clerk API calls (defaults to 10)
|
||||
allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients.
|
||||
If None (default), all URIs are allowed. If empty list, no URIs are allowed.
|
||||
client_storage: Storage backend for OAuth state (client registrations, encrypted tokens).
|
||||
If None, an encrypted file store will be created in the data directory
|
||||
(derived from ``platformdirs``).
|
||||
jwt_signing_key: Secret for signing FastMCP JWT tokens (any string or bytes). If bytes
|
||||
are provided, they will be used as is. If a string is provided, it will be derived
|
||||
into a 32-byte key. If not provided, the upstream client secret will be used to
|
||||
derive a 32-byte key using PBKDF2.
|
||||
require_authorization_consent: Whether to require user consent before authorizing
|
||||
clients (default True). When "external", the built-in consent screen is skipped
|
||||
but no warning is logged, indicating that consent is handled externally by Clerk.
|
||||
consent_csp_policy: Custom CSP policy for the consent page.
|
||||
extra_authorize_params: Additional parameters to forward to Clerk's authorization
|
||||
endpoint. Example: {"prompt": "login"} to force re-authentication.
|
||||
http_client: Optional httpx.AsyncClient for connection pooling in token verification.
|
||||
When provided, the client is reused across verify_token calls and the caller
|
||||
is responsible for its lifecycle. When None (default), a fresh client is created
|
||||
per call.
|
||||
enable_cimd: Enable CIMD (Client ID Metadata Document) support for URL-based
|
||||
client IDs (default True). Set to False to disable.
|
||||
"""
|
||||
domain = domain.rstrip("/")
|
||||
|
||||
required_scopes_final = (
|
||||
parse_scopes(required_scopes)
|
||||
if required_scopes is not None
|
||||
else ["openid", "email", "profile"]
|
||||
)
|
||||
|
||||
parsed_valid_scopes = (
|
||||
parse_scopes(valid_scopes) if valid_scopes is not None else None
|
||||
)
|
||||
|
||||
token_verifier = ClerkTokenVerifier(
|
||||
domain=domain,
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
required_scopes=required_scopes_final,
|
||||
timeout_seconds=timeout_seconds,
|
||||
http_client=http_client,
|
||||
)
|
||||
|
||||
extra_authorize_params_final = (
|
||||
dict(extra_authorize_params) if extra_authorize_params else {}
|
||||
)
|
||||
|
||||
super().__init__(
|
||||
upstream_authorization_endpoint=f"https://{domain}/oauth/authorize",
|
||||
upstream_token_endpoint=f"https://{domain}/oauth/token",
|
||||
upstream_client_id=client_id,
|
||||
upstream_client_secret=client_secret,
|
||||
token_verifier=token_verifier,
|
||||
base_url=base_url,
|
||||
resource_base_url=resource_base_url,
|
||||
redirect_path=redirect_path,
|
||||
issuer_url=issuer_url or base_url,
|
||||
allowed_client_redirect_uris=allowed_client_redirect_uris,
|
||||
client_storage=client_storage,
|
||||
jwt_signing_key=jwt_signing_key,
|
||||
require_authorization_consent=require_authorization_consent,
|
||||
consent_csp_policy=consent_csp_policy,
|
||||
forward_resource=forward_resource,
|
||||
extra_authorize_params=extra_authorize_params_final or None,
|
||||
valid_scopes=parsed_valid_scopes,
|
||||
enable_cimd=enable_cimd,
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"Initialized Clerk OAuth provider for domain %s with scopes: %s",
|
||||
domain,
|
||||
required_scopes_final,
|
||||
)
|
||||
__all__ = ["ClerkProvider", "ClerkTokenVerifier"]
|
||||
|
|
|
|||
|
|
@ -1,209 +1,20 @@
|
|||
"""Descope authentication provider for FastMCP.
|
||||
|
||||
This module provides DescopeProvider - a complete authentication solution that integrates
|
||||
with Descope's OAuth 2.1 and OpenID Connect services, supporting Dynamic Client Registration (DCR)
|
||||
for seamless MCP client authentication.
|
||||
"""
|
||||
"""Backward compatibility shim for Descope auth provider."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from urllib.parse import urlparse
|
||||
import warnings
|
||||
|
||||
import httpx
|
||||
from pydantic import AnyHttpUrl
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.routing import Route
|
||||
from fastmcp import settings
|
||||
from fastmcp.exceptions import FastMCPDeprecationWarning
|
||||
|
||||
from fastmcp.server.auth import RemoteAuthProvider, TokenVerifier
|
||||
from fastmcp.server.auth.providers.jwt import JWTVerifier
|
||||
from fastmcp.utilities.auth import parse_scopes
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
if settings.deprecation_warnings:
|
||||
warnings.warn(
|
||||
"fastmcp.server.auth.providers.descope is deprecated. "
|
||||
"Import from fastmcp.server.plugins.auth.descope.provider instead.",
|
||||
FastMCPDeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
from fastmcp.server.plugins.auth.descope.provider import DescopeProvider
|
||||
|
||||
|
||||
class DescopeProvider(RemoteAuthProvider):
|
||||
"""Descope metadata provider for DCR (Dynamic Client Registration).
|
||||
|
||||
This provider implements Descope integration using metadata forwarding.
|
||||
This is the recommended approach for Descope DCR
|
||||
as it allows Descope to handle the OAuth flow directly while FastMCP acts
|
||||
as a resource server.
|
||||
|
||||
IMPORTANT SETUP REQUIREMENTS:
|
||||
|
||||
1. Create an MCP Server in Descope Console:
|
||||
- Go to the [MCP Servers page](https://app.descope.com/mcp-servers) of the Descope Console
|
||||
- Create a new MCP Server
|
||||
- Ensure that **Dynamic Client Registration (DCR)** is enabled
|
||||
- Note your Well-Known URL
|
||||
|
||||
2. Note your Well-Known URL:
|
||||
- Save your Well-Known URL from [MCP Server Settings](https://app.descope.com/mcp-servers)
|
||||
- Format: ``https://.../v1/apps/agentic/P.../M.../.well-known/openid-configuration``
|
||||
|
||||
For detailed setup instructions, see:
|
||||
https://docs.descope.com/identity-federation/inbound-apps/creating-inbound-apps#method-2-dynamic-client-registration-dcr
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastmcp.server.auth.providers.descope import DescopeProvider
|
||||
|
||||
# Create Descope metadata provider (JWT verifier created automatically)
|
||||
descope_auth = DescopeProvider(
|
||||
config_url="https://.../v1/apps/agentic/P.../M.../.well-known/openid-configuration",
|
||||
base_url="https://your-fastmcp-server.com",
|
||||
)
|
||||
|
||||
# Use with FastMCP
|
||||
mcp = FastMCP("My App", auth=descope_auth)
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
base_url: AnyHttpUrl | str,
|
||||
config_url: AnyHttpUrl | str | None = None,
|
||||
project_id: str | None = None,
|
||||
descope_base_url: AnyHttpUrl | str | None = None,
|
||||
required_scopes: list[str] | None = None,
|
||||
scopes_supported: list[str] | None = None,
|
||||
resource_name: str | None = None,
|
||||
resource_documentation: AnyHttpUrl | None = None,
|
||||
token_verifier: TokenVerifier | None = None,
|
||||
):
|
||||
"""Initialize Descope metadata provider.
|
||||
|
||||
Args:
|
||||
base_url: Public URL of this FastMCP server
|
||||
config_url: Your Descope Well-Known URL (e.g., "https://.../v1/apps/agentic/P.../M.../.well-known/openid-configuration")
|
||||
This is the new recommended way. If provided, project_id and descope_base_url are ignored.
|
||||
project_id: Your Descope Project ID (e.g., "P2abc123"). Used with descope_base_url for backwards compatibility.
|
||||
descope_base_url: Your Descope base URL (e.g., "https://api.descope.com"). Used with project_id for backwards compatibility.
|
||||
required_scopes: Optional list of scopes that must be present in validated tokens.
|
||||
These scopes will be included in the protected resource metadata.
|
||||
scopes_supported: Optional list of scopes to advertise in OAuth metadata.
|
||||
If None, uses required_scopes. Use this when the scopes clients should
|
||||
request differ from the scopes enforced on tokens.
|
||||
resource_name: Optional name for the protected resource metadata.
|
||||
resource_documentation: Optional documentation URL for the protected resource.
|
||||
token_verifier: Optional token verifier. If None, creates JWT verifier for Descope
|
||||
"""
|
||||
self.base_url = AnyHttpUrl(str(base_url).rstrip("/"))
|
||||
|
||||
# Parse scopes if provided as string
|
||||
parsed_scopes = (
|
||||
parse_scopes(required_scopes) if required_scopes is not None else None
|
||||
)
|
||||
|
||||
# Determine which API is being used
|
||||
if config_url is not None:
|
||||
# New API: use config_url
|
||||
# Strip /.well-known/openid-configuration from config_url if present
|
||||
issuer_url = str(config_url)
|
||||
if issuer_url.endswith("/.well-known/openid-configuration"):
|
||||
issuer_url = issuer_url[: -len("/.well-known/openid-configuration")]
|
||||
|
||||
# Parse the issuer URL to extract descope_base_url and project_id for other uses
|
||||
parsed_url = urlparse(issuer_url)
|
||||
path_parts = parsed_url.path.strip("/").split("/")
|
||||
|
||||
# Extract project_id from path (format: /v1/apps/agentic/P.../M...)
|
||||
if "agentic" in path_parts:
|
||||
agentic_index = path_parts.index("agentic")
|
||||
if agentic_index + 1 < len(path_parts):
|
||||
self.project_id = path_parts[agentic_index + 1]
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Could not extract project_id from config_url: {issuer_url}"
|
||||
)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Could not find 'agentic' in config_url path: {issuer_url}"
|
||||
)
|
||||
|
||||
# Extract descope_base_url (scheme + netloc)
|
||||
self.descope_base_url = f"{parsed_url.scheme}://{parsed_url.netloc}".rstrip(
|
||||
"/"
|
||||
)
|
||||
elif project_id is not None and descope_base_url is not None:
|
||||
# Old API: use project_id and descope_base_url
|
||||
self.project_id = project_id
|
||||
descope_base_url_str = str(descope_base_url).rstrip("/")
|
||||
# Ensure descope_base_url has a scheme
|
||||
if not descope_base_url_str.startswith(("http://", "https://")):
|
||||
descope_base_url_str = f"https://{descope_base_url_str}"
|
||||
self.descope_base_url = descope_base_url_str
|
||||
# Old issuer format
|
||||
issuer_url = f"{self.descope_base_url}/v1/apps/{self.project_id}"
|
||||
else:
|
||||
raise ValueError(
|
||||
"Either config_url (new API) or both project_id and descope_base_url (old API) must be provided"
|
||||
)
|
||||
|
||||
# Create default JWT verifier if none provided
|
||||
if token_verifier is None:
|
||||
token_verifier = JWTVerifier(
|
||||
jwks_uri=f"{self.descope_base_url}/{self.project_id}/.well-known/jwks.json",
|
||||
issuer=issuer_url,
|
||||
algorithm="RS256",
|
||||
audience=self.project_id,
|
||||
required_scopes=parsed_scopes,
|
||||
)
|
||||
|
||||
# Initialize RemoteAuthProvider with Descope as the authorization server
|
||||
super().__init__(
|
||||
token_verifier=token_verifier,
|
||||
authorization_servers=[AnyHttpUrl(issuer_url)],
|
||||
base_url=self.base_url,
|
||||
scopes_supported=scopes_supported,
|
||||
resource_name=resource_name,
|
||||
resource_documentation=resource_documentation,
|
||||
)
|
||||
|
||||
def get_routes(
|
||||
self,
|
||||
mcp_path: str | None = None,
|
||||
) -> list[Route]:
|
||||
"""Get OAuth routes including Descope authorization server metadata forwarding.
|
||||
|
||||
This returns the standard protected resource routes plus an authorization server
|
||||
metadata endpoint that forwards Descope's OAuth metadata to clients.
|
||||
|
||||
Args:
|
||||
mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp")
|
||||
This is used to advertise the resource URL in metadata.
|
||||
"""
|
||||
# Get the standard protected resource routes from RemoteAuthProvider
|
||||
routes = super().get_routes(mcp_path)
|
||||
|
||||
async def oauth_authorization_server_metadata(request):
|
||||
"""Forward Descope OAuth authorization server metadata with FastMCP customizations."""
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
f"{self.descope_base_url}/v1/apps/{self.project_id}/.well-known/oauth-authorization-server"
|
||||
)
|
||||
response.raise_for_status()
|
||||
metadata = response.json()
|
||||
return JSONResponse(metadata)
|
||||
except Exception as e:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "server_error",
|
||||
"error_description": f"Failed to fetch Descope metadata: {e}",
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
# Add Descope authorization server metadata forwarding
|
||||
routes.append(
|
||||
Route(
|
||||
"/.well-known/oauth-authorization-server",
|
||||
endpoint=oauth_authorization_server_metadata,
|
||||
methods=["GET"],
|
||||
)
|
||||
)
|
||||
|
||||
return routes
|
||||
__all__ = ["DescopeProvider"]
|
||||
|
|
|
|||
|
|
@ -1,288 +1,23 @@
|
|||
"""Discord OAuth provider for FastMCP.
|
||||
|
||||
This module provides a complete Discord OAuth integration that's ready to use
|
||||
with just a client ID and client secret. It handles all the complexity of
|
||||
Discord's OAuth flow, token validation, and user management.
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.discord import DiscordProvider
|
||||
|
||||
# Simple Discord OAuth protection
|
||||
auth = DiscordProvider(
|
||||
client_id="your-discord-client-id",
|
||||
client_secret="your-discord-client-secret"
|
||||
)
|
||||
|
||||
mcp = FastMCP("My Protected Server", auth=auth)
|
||||
```
|
||||
"""
|
||||
"""Backward compatibility shim for Discord auth provider."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
import warnings
|
||||
|
||||
import httpx
|
||||
from key_value.aio.protocols import AsyncKeyValue
|
||||
from pydantic import AnyHttpUrl
|
||||
from fastmcp import settings
|
||||
from fastmcp.exceptions import FastMCPDeprecationWarning
|
||||
|
||||
from fastmcp.server.auth import TokenVerifier
|
||||
from fastmcp.server.auth.auth import AccessToken
|
||||
from fastmcp.server.auth.oauth_proxy import OAuthProxy
|
||||
from fastmcp.utilities.auth import parse_scopes
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
if settings.deprecation_warnings:
|
||||
warnings.warn(
|
||||
"fastmcp.server.auth.providers.discord is deprecated. "
|
||||
"Import from fastmcp.server.plugins.auth.discord.provider instead.",
|
||||
FastMCPDeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
from fastmcp.server.plugins.auth.discord.provider import (
|
||||
DiscordProvider,
|
||||
DiscordTokenVerifier,
|
||||
)
|
||||
|
||||
|
||||
class DiscordTokenVerifier(TokenVerifier):
|
||||
"""Token verifier for Discord OAuth tokens.
|
||||
|
||||
Discord OAuth tokens are opaque (not JWTs), so we verify them
|
||||
by calling Discord's tokeninfo API to check if they're valid and get user info.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
expected_client_id: str,
|
||||
required_scopes: list[str] | None = None,
|
||||
timeout_seconds: int = 10,
|
||||
http_client: httpx.AsyncClient | None = None,
|
||||
):
|
||||
"""Initialize the Discord token verifier.
|
||||
|
||||
Args:
|
||||
expected_client_id: Expected Discord OAuth client ID for audience binding
|
||||
required_scopes: Required OAuth scopes (e.g., ['email'])
|
||||
timeout_seconds: HTTP request timeout
|
||||
http_client: Optional httpx.AsyncClient for connection pooling. When provided,
|
||||
the client is reused across calls and the caller is responsible for its
|
||||
lifecycle. When None (default), a fresh client is created per call.
|
||||
"""
|
||||
super().__init__(required_scopes=required_scopes)
|
||||
self.expected_client_id = expected_client_id
|
||||
self.timeout_seconds = timeout_seconds
|
||||
self._http_client = http_client
|
||||
|
||||
async def verify_token(self, token: str) -> AccessToken | None:
|
||||
"""Verify Discord OAuth token by calling Discord's tokeninfo API."""
|
||||
try:
|
||||
async with (
|
||||
contextlib.nullcontext(self._http_client)
|
||||
if self._http_client is not None
|
||||
else httpx.AsyncClient(timeout=self.timeout_seconds)
|
||||
) as client:
|
||||
# Use Discord's tokeninfo endpoint to validate the token
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"User-Agent": "FastMCP-Discord-OAuth",
|
||||
}
|
||||
response = await client.get(
|
||||
"https://discord.com/api/oauth2/@me",
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.debug(
|
||||
"Discord token verification failed: %d",
|
||||
response.status_code,
|
||||
)
|
||||
return None
|
||||
|
||||
token_info = response.json()
|
||||
|
||||
# Check if token is expired (Discord returns ISO timestamp)
|
||||
expires_str = token_info.get("expires")
|
||||
expires_at = None
|
||||
if expires_str:
|
||||
expires_dt = datetime.fromisoformat(
|
||||
expires_str.replace("Z", "+00:00")
|
||||
)
|
||||
expires_at = int(expires_dt.timestamp())
|
||||
if expires_at <= int(time.time()):
|
||||
logger.debug("Discord token has expired")
|
||||
return None
|
||||
|
||||
token_scopes = token_info.get("scopes", [])
|
||||
|
||||
# Check required scopes
|
||||
if self.required_scopes:
|
||||
token_scopes_set = set(token_scopes)
|
||||
required_scopes_set = set(self.required_scopes)
|
||||
if not required_scopes_set.issubset(token_scopes_set):
|
||||
logger.debug(
|
||||
"Discord token missing required scopes. Has %d, needs %d",
|
||||
len(token_scopes_set),
|
||||
len(required_scopes_set),
|
||||
)
|
||||
return None
|
||||
|
||||
user_data = token_info.get("user", {})
|
||||
application = token_info.get("application") or {}
|
||||
client_id = str(application.get("id", "unknown"))
|
||||
if client_id != self.expected_client_id:
|
||||
logger.debug(
|
||||
"Discord token app ID mismatch: expected %s, got %s",
|
||||
self.expected_client_id,
|
||||
client_id,
|
||||
)
|
||||
return None
|
||||
|
||||
# Create AccessToken with Discord user info
|
||||
access_token = AccessToken(
|
||||
token=token,
|
||||
client_id=client_id,
|
||||
scopes=token_scopes,
|
||||
expires_at=expires_at,
|
||||
claims={
|
||||
"sub": user_data.get("id"),
|
||||
"username": user_data.get("username"),
|
||||
"discriminator": user_data.get("discriminator"),
|
||||
"avatar": user_data.get("avatar"),
|
||||
"email": user_data.get("email"),
|
||||
"verified": user_data.get("verified"),
|
||||
"locale": user_data.get("locale"),
|
||||
"discord_user": user_data,
|
||||
"discord_token_info": token_info,
|
||||
},
|
||||
)
|
||||
logger.debug("Discord token verified successfully")
|
||||
return access_token
|
||||
|
||||
except httpx.RequestError as e:
|
||||
logger.debug("Failed to verify Discord token: %s", e)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.debug("Discord token verification error: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
class DiscordProvider(OAuthProxy):
|
||||
"""Complete Discord OAuth provider for FastMCP.
|
||||
|
||||
This provider makes it trivial to add Discord OAuth protection to any
|
||||
FastMCP server. Just provide your Discord OAuth app credentials and
|
||||
a base URL, and you're ready to go.
|
||||
|
||||
Features:
|
||||
- Transparent OAuth proxy to Discord
|
||||
- Automatic token validation via Discord's API
|
||||
- User information extraction from Discord APIs
|
||||
- Minimal configuration required
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.discord import DiscordProvider
|
||||
|
||||
auth = DiscordProvider(
|
||||
client_id="123456789",
|
||||
client_secret="discord-client-secret-abc123...",
|
||||
base_url="https://my-server.com"
|
||||
)
|
||||
|
||||
mcp = FastMCP("My App", auth=auth)
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
client_id: str,
|
||||
client_secret: str,
|
||||
base_url: AnyHttpUrl | str,
|
||||
resource_base_url: AnyHttpUrl | str | None = None,
|
||||
issuer_url: AnyHttpUrl | str | None = None,
|
||||
redirect_path: str | None = None,
|
||||
required_scopes: list[str] | None = None,
|
||||
timeout_seconds: int = 10,
|
||||
allowed_client_redirect_uris: list[str] | None = None,
|
||||
client_storage: AsyncKeyValue | None = None,
|
||||
jwt_signing_key: str | bytes | None = None,
|
||||
require_authorization_consent: bool | Literal["remember", "external"] = True,
|
||||
consent_csp_policy: str | None = None,
|
||||
forward_resource: bool = True,
|
||||
http_client: httpx.AsyncClient | None = None,
|
||||
enable_cimd: bool = True,
|
||||
):
|
||||
"""Initialize Discord OAuth provider.
|
||||
|
||||
Args:
|
||||
client_id: Discord OAuth client ID (e.g., "123456789")
|
||||
client_secret: Discord OAuth client secret (e.g., "S....")
|
||||
base_url: Public URL where OAuth endpoints will be accessible (includes any mount path)
|
||||
resource_base_url: Optional public base URL for the protected resource metadata
|
||||
and token audience. Defaults to ``base_url``.
|
||||
issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL
|
||||
to avoid 404s during discovery when mounting under a path.
|
||||
redirect_path: Redirect path configured in Discord OAuth app (defaults to "/auth/callback")
|
||||
required_scopes: Required Discord scopes (defaults to ["identify"]). Common scopes include:
|
||||
- "identify" for profile info (default)
|
||||
- "email" for email access
|
||||
- "guilds" for server membership info
|
||||
timeout_seconds: HTTP request timeout for Discord API calls (defaults to 10)
|
||||
allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients.
|
||||
If None (default), all URIs are allowed. If empty list, no URIs are allowed.
|
||||
client_storage: Storage backend for OAuth state (client registrations, encrypted tokens).
|
||||
If None, an encrypted file store will be created in the data directory
|
||||
(derived from `platformdirs`).
|
||||
jwt_signing_key: Secret for signing FastMCP JWT tokens (any string or bytes). If bytes are provided,
|
||||
they will be used as is. If a string is provided, it will be derived into a 32-byte key. If not
|
||||
provided, the upstream client secret will be used to derive a 32-byte key using PBKDF2.
|
||||
require_authorization_consent: Whether to require user consent before authorizing clients (default True).
|
||||
When True, users see a consent screen before being redirected to Discord.
|
||||
When False, authorization proceeds directly without user confirmation.
|
||||
When "external", the built-in consent screen is skipped but no warning is
|
||||
logged, indicating that consent is handled externally (e.g. by the upstream IdP).
|
||||
SECURITY WARNING: Only set to False for local development or testing environments.
|
||||
http_client: Optional httpx.AsyncClient for connection pooling in token verification.
|
||||
When provided, the client is reused across verify_token calls and the caller
|
||||
is responsible for its lifecycle. When None (default), a fresh client is created per call.
|
||||
enable_cimd: Enable CIMD (Client ID Metadata Document) support for URL-based
|
||||
client IDs (default True). Set to False to disable.
|
||||
"""
|
||||
# Parse scopes if provided as string
|
||||
required_scopes_final = (
|
||||
parse_scopes(required_scopes)
|
||||
if required_scopes is not None
|
||||
else ["identify"]
|
||||
)
|
||||
|
||||
# Create Discord token verifier
|
||||
token_verifier = DiscordTokenVerifier(
|
||||
expected_client_id=client_id,
|
||||
required_scopes=required_scopes_final,
|
||||
timeout_seconds=timeout_seconds,
|
||||
http_client=http_client,
|
||||
)
|
||||
|
||||
# Initialize OAuth proxy with Discord endpoints
|
||||
super().__init__(
|
||||
upstream_authorization_endpoint="https://discord.com/oauth2/authorize",
|
||||
upstream_token_endpoint="https://discord.com/api/oauth2/token",
|
||||
upstream_client_id=client_id,
|
||||
upstream_client_secret=client_secret,
|
||||
token_verifier=token_verifier,
|
||||
base_url=base_url,
|
||||
resource_base_url=resource_base_url,
|
||||
redirect_path=redirect_path,
|
||||
issuer_url=issuer_url or base_url, # Default to base_url if not specified
|
||||
allowed_client_redirect_uris=allowed_client_redirect_uris,
|
||||
client_storage=client_storage,
|
||||
jwt_signing_key=jwt_signing_key,
|
||||
require_authorization_consent=require_authorization_consent,
|
||||
consent_csp_policy=consent_csp_policy,
|
||||
forward_resource=forward_resource,
|
||||
enable_cimd=enable_cimd,
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"Initialized Discord OAuth provider for client %s with scopes: %s",
|
||||
client_id,
|
||||
required_scopes_final,
|
||||
)
|
||||
__all__ = ["DiscordProvider", "DiscordTokenVerifier"]
|
||||
|
|
|
|||
|
|
@ -1,303 +1,23 @@
|
|||
"""GitHub OAuth provider for FastMCP.
|
||||
|
||||
This module provides a complete GitHub OAuth integration that's ready to use
|
||||
with just a client ID and client secret. It handles all the complexity of
|
||||
GitHub's OAuth flow, token validation, and user management.
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.github import GitHubProvider
|
||||
|
||||
# Simple GitHub OAuth protection
|
||||
auth = GitHubProvider(
|
||||
client_id="your-github-client-id",
|
||||
client_secret="your-github-client-secret"
|
||||
)
|
||||
|
||||
mcp = FastMCP("My Protected Server", auth=auth)
|
||||
```
|
||||
"""
|
||||
"""Backward compatibility shim for GitHub auth provider."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
from typing import Literal
|
||||
import warnings
|
||||
|
||||
import httpx
|
||||
from key_value.aio.protocols import AsyncKeyValue
|
||||
from pydantic import AnyHttpUrl
|
||||
from fastmcp import settings
|
||||
from fastmcp.exceptions import FastMCPDeprecationWarning
|
||||
|
||||
from fastmcp.server.auth import TokenVerifier
|
||||
from fastmcp.server.auth.auth import AccessToken
|
||||
from fastmcp.server.auth.oauth_proxy import OAuthProxy
|
||||
from fastmcp.utilities.auth import parse_scopes
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
from fastmcp.utilities.token_cache import TokenCache
|
||||
if settings.deprecation_warnings:
|
||||
warnings.warn(
|
||||
"fastmcp.server.auth.providers.github is deprecated. "
|
||||
"Import from fastmcp.server.plugins.auth.github.provider instead.",
|
||||
FastMCPDeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
from fastmcp.server.plugins.auth.github.provider import (
|
||||
GitHubProvider,
|
||||
GitHubTokenVerifier,
|
||||
)
|
||||
|
||||
|
||||
class GitHubTokenVerifier(TokenVerifier):
|
||||
"""Token verifier for GitHub OAuth tokens.
|
||||
|
||||
GitHub OAuth tokens are opaque (not JWTs), so we verify them
|
||||
by calling GitHub's API to check if they're valid and get user info.
|
||||
|
||||
Caching is disabled by default. Set ``cache_ttl_seconds`` to a positive
|
||||
integer to cache successful verification results and avoid repeated
|
||||
GitHub API calls for the same token.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
required_scopes: list[str] | None = None,
|
||||
timeout_seconds: int = 10,
|
||||
cache_ttl_seconds: int | None = None,
|
||||
max_cache_size: int | None = None,
|
||||
http_client: httpx.AsyncClient | None = None,
|
||||
):
|
||||
"""Initialize the GitHub token verifier.
|
||||
|
||||
Args:
|
||||
required_scopes: Required OAuth scopes (e.g., ['user:email'])
|
||||
timeout_seconds: HTTP request timeout
|
||||
cache_ttl_seconds: How long to cache verification results in seconds.
|
||||
Caching is disabled by default (None). Set to a positive integer
|
||||
to enable (e.g., 300 for 5 minutes).
|
||||
max_cache_size: Maximum number of tokens to cache. Default: 10 000.
|
||||
http_client: Optional httpx.AsyncClient for connection pooling. When provided,
|
||||
the client is reused across calls and the caller is responsible for its
|
||||
lifecycle. When None (default), a fresh client is created per call.
|
||||
"""
|
||||
super().__init__(required_scopes=required_scopes)
|
||||
self.timeout_seconds = timeout_seconds
|
||||
self._http_client = http_client
|
||||
self._cache = TokenCache(
|
||||
ttl_seconds=cache_ttl_seconds,
|
||||
max_size=max_cache_size,
|
||||
)
|
||||
|
||||
async def verify_token(self, token: str) -> AccessToken | None:
|
||||
"""Verify GitHub OAuth token by calling GitHub API."""
|
||||
is_cached, cached_result = self._cache.get(token)
|
||||
if is_cached:
|
||||
logger.debug("GitHub token cache hit")
|
||||
return cached_result
|
||||
|
||||
try:
|
||||
async with (
|
||||
contextlib.nullcontext(self._http_client)
|
||||
if self._http_client is not None
|
||||
else httpx.AsyncClient(timeout=self.timeout_seconds)
|
||||
) as client:
|
||||
# Get token info from GitHub API
|
||||
response = await client.get(
|
||||
"https://api.github.com/user",
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Accept": "application/vnd.github.v3+json",
|
||||
"User-Agent": "FastMCP-GitHub-OAuth",
|
||||
},
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.debug(
|
||||
"GitHub token verification failed: %d - %s",
|
||||
response.status_code,
|
||||
response.text[:200],
|
||||
)
|
||||
return None
|
||||
|
||||
user_data = response.json()
|
||||
|
||||
# Get token scopes from GitHub API
|
||||
# GitHub includes scopes in the X-OAuth-Scopes header
|
||||
scopes_response = await client.get(
|
||||
"https://api.github.com/user/repos", # Any authenticated endpoint
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Accept": "application/vnd.github.v3+json",
|
||||
"User-Agent": "FastMCP-GitHub-OAuth",
|
||||
},
|
||||
)
|
||||
|
||||
# Extract scopes from X-OAuth-Scopes header if available
|
||||
scopes_verified = scopes_response.status_code == 200
|
||||
oauth_scopes_header = scopes_response.headers.get("x-oauth-scopes", "")
|
||||
token_scopes = [
|
||||
scope.strip()
|
||||
for scope in oauth_scopes_header.split(",")
|
||||
if scope.strip()
|
||||
]
|
||||
|
||||
# If no scopes in header, assume basic scopes based on successful user API call
|
||||
if not token_scopes:
|
||||
token_scopes = ["user"] # Basic scope if we can access user info
|
||||
|
||||
# Check required scopes
|
||||
if self.required_scopes:
|
||||
token_scopes_set = set(token_scopes)
|
||||
required_scopes_set = set(self.required_scopes)
|
||||
if not required_scopes_set.issubset(token_scopes_set):
|
||||
logger.debug(
|
||||
"GitHub token missing required scopes. Has %d, needs %d",
|
||||
len(token_scopes_set),
|
||||
len(required_scopes_set),
|
||||
)
|
||||
return None
|
||||
|
||||
# Create AccessToken with GitHub user info
|
||||
result = AccessToken(
|
||||
token=token,
|
||||
client_id=str(user_data.get("id", "unknown")), # Use GitHub user ID
|
||||
scopes=token_scopes,
|
||||
expires_at=None, # GitHub tokens don't typically expire
|
||||
claims={
|
||||
"sub": str(user_data["id"]),
|
||||
"login": user_data.get("login"),
|
||||
"name": user_data.get("name"),
|
||||
"email": user_data.get("email"),
|
||||
"avatar_url": user_data.get("avatar_url"),
|
||||
"github_user_data": user_data,
|
||||
},
|
||||
)
|
||||
if scopes_verified:
|
||||
self._cache.set(token, result)
|
||||
return result
|
||||
|
||||
except httpx.RequestError as e:
|
||||
logger.debug("Failed to verify GitHub token: %s", e)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.debug("GitHub token verification error: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
class GitHubProvider(OAuthProxy):
|
||||
"""Complete GitHub OAuth provider for FastMCP.
|
||||
|
||||
This provider makes it trivial to add GitHub OAuth protection to any
|
||||
FastMCP server. Just provide your GitHub OAuth app credentials and
|
||||
a base URL, and you're ready to go.
|
||||
|
||||
Features:
|
||||
- Transparent OAuth proxy to GitHub
|
||||
- Automatic token validation via GitHub API
|
||||
- User information extraction
|
||||
- Minimal configuration required
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.github import GitHubProvider
|
||||
|
||||
auth = GitHubProvider(
|
||||
client_id="Ov23li...",
|
||||
client_secret="abc123...",
|
||||
base_url="https://my-server.com"
|
||||
)
|
||||
|
||||
mcp = FastMCP("My App", auth=auth)
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
client_id: str,
|
||||
client_secret: str,
|
||||
base_url: AnyHttpUrl | str,
|
||||
resource_base_url: AnyHttpUrl | str | None = None,
|
||||
issuer_url: AnyHttpUrl | str | None = None,
|
||||
redirect_path: str | None = None,
|
||||
required_scopes: list[str] | None = None,
|
||||
timeout_seconds: int = 10,
|
||||
cache_ttl_seconds: int | None = None,
|
||||
max_cache_size: int | None = None,
|
||||
allowed_client_redirect_uris: list[str] | None = None,
|
||||
client_storage: AsyncKeyValue | None = None,
|
||||
jwt_signing_key: str | bytes | None = None,
|
||||
require_authorization_consent: bool | Literal["remember", "external"] = True,
|
||||
consent_csp_policy: str | None = None,
|
||||
forward_resource: bool = True,
|
||||
http_client: httpx.AsyncClient | None = None,
|
||||
enable_cimd: bool = True,
|
||||
):
|
||||
"""Initialize GitHub OAuth provider.
|
||||
|
||||
Args:
|
||||
client_id: GitHub OAuth app client ID (e.g., "Ov23li...")
|
||||
client_secret: GitHub OAuth app client secret
|
||||
base_url: Public URL where OAuth endpoints will be accessible (includes any mount path)
|
||||
resource_base_url: Optional public base URL for the protected resource metadata
|
||||
and token audience. Defaults to ``base_url``.
|
||||
issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL
|
||||
to avoid 404s during discovery when mounting under a path.
|
||||
redirect_path: Redirect path configured in GitHub OAuth app (defaults to "/auth/callback")
|
||||
required_scopes: Required GitHub scopes (defaults to ["user"])
|
||||
timeout_seconds: HTTP request timeout for GitHub API calls (defaults to 10)
|
||||
cache_ttl_seconds: How long to cache token verification results in seconds.
|
||||
Caching is disabled by default (None). Set to a positive integer to
|
||||
enable (e.g., 300 for 5 minutes).
|
||||
max_cache_size: Maximum number of tokens to cache. Default: 10 000.
|
||||
allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients.
|
||||
If None (default), all URIs are allowed. If empty list, no URIs are allowed.
|
||||
client_storage: Storage backend for OAuth state (client registrations, encrypted tokens).
|
||||
If None, an encrypted file store will be created in the data directory
|
||||
(derived from `platformdirs`).
|
||||
jwt_signing_key: Secret for signing FastMCP JWT tokens (any string or bytes). If bytes are provided,
|
||||
they will be used as is. If a string is provided, it will be derived into a 32-byte key. If not
|
||||
provided, the upstream client secret will be used to derive a 32-byte key using PBKDF2.
|
||||
require_authorization_consent: Whether to require user consent before authorizing clients (default True).
|
||||
When True, users see a consent screen before being redirected to GitHub.
|
||||
When False, authorization proceeds directly without user confirmation.
|
||||
When "external", the built-in consent screen is skipped but no warning is
|
||||
logged, indicating that consent is handled externally (e.g. by the upstream IdP).
|
||||
SECURITY WARNING: Only set to False for local development or testing environments.
|
||||
http_client: Optional httpx.AsyncClient for connection pooling in token verification.
|
||||
When provided, the client is reused across verify_token calls and the caller
|
||||
is responsible for its lifecycle. When None (default), a fresh client is created per call.
|
||||
enable_cimd: Enable CIMD (Client ID Metadata Document) support for URL-based
|
||||
client IDs (default True). Set to False to disable.
|
||||
"""
|
||||
# Parse scopes if provided as string
|
||||
required_scopes_final = (
|
||||
parse_scopes(required_scopes) if required_scopes is not None else ["user"]
|
||||
)
|
||||
|
||||
# Create GitHub token verifier
|
||||
token_verifier = GitHubTokenVerifier(
|
||||
required_scopes=required_scopes_final,
|
||||
timeout_seconds=timeout_seconds,
|
||||
cache_ttl_seconds=cache_ttl_seconds,
|
||||
max_cache_size=max_cache_size,
|
||||
http_client=http_client,
|
||||
)
|
||||
|
||||
# Initialize OAuth proxy with GitHub endpoints
|
||||
super().__init__(
|
||||
upstream_authorization_endpoint="https://github.com/login/oauth/authorize",
|
||||
upstream_token_endpoint="https://github.com/login/oauth/access_token",
|
||||
upstream_client_id=client_id,
|
||||
upstream_client_secret=client_secret,
|
||||
token_verifier=token_verifier,
|
||||
base_url=base_url,
|
||||
resource_base_url=resource_base_url,
|
||||
redirect_path=redirect_path,
|
||||
issuer_url=issuer_url or base_url, # Default to base_url if not specified
|
||||
allowed_client_redirect_uris=allowed_client_redirect_uris,
|
||||
client_storage=client_storage,
|
||||
jwt_signing_key=jwt_signing_key,
|
||||
require_authorization_consent=require_authorization_consent,
|
||||
consent_csp_policy=consent_csp_policy,
|
||||
forward_resource=forward_resource,
|
||||
enable_cimd=enable_cimd,
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"Initialized GitHub OAuth provider for client %s with scopes: %s",
|
||||
client_id,
|
||||
required_scopes_final,
|
||||
)
|
||||
__all__ = ["GitHubProvider", "GitHubTokenVerifier"]
|
||||
|
|
|
|||
|
|
@ -1,365 +1,23 @@
|
|||
"""Google OAuth provider for FastMCP.
|
||||
|
||||
This module provides a complete Google OAuth integration that's ready to use
|
||||
with just a client ID and client secret. It handles all the complexity of
|
||||
Google's OAuth flow, token validation, and user management.
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.google import GoogleProvider
|
||||
|
||||
# Simple Google OAuth protection
|
||||
auth = GoogleProvider(
|
||||
client_id="your-google-client-id.apps.googleusercontent.com",
|
||||
client_secret="your-google-client-secret"
|
||||
)
|
||||
|
||||
mcp = FastMCP("My Protected Server", auth=auth)
|
||||
```
|
||||
"""
|
||||
"""Backward compatibility shim for Google auth provider."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import time
|
||||
from typing import Literal
|
||||
import warnings
|
||||
|
||||
import httpx
|
||||
from key_value.aio.protocols import AsyncKeyValue
|
||||
from pydantic import AnyHttpUrl
|
||||
from fastmcp import settings
|
||||
from fastmcp.exceptions import FastMCPDeprecationWarning
|
||||
|
||||
from fastmcp.server.auth import TokenVerifier
|
||||
from fastmcp.server.auth.auth import AccessToken
|
||||
from fastmcp.server.auth.oauth_proxy import OAuthProxy
|
||||
from fastmcp.utilities.auth import parse_scopes
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
if settings.deprecation_warnings:
|
||||
warnings.warn(
|
||||
"fastmcp.server.auth.providers.google is deprecated. "
|
||||
"Import from fastmcp.server.plugins.auth.google.provider instead.",
|
||||
FastMCPDeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
from fastmcp.server.plugins.auth.google.provider import (
|
||||
GoogleProvider,
|
||||
GoogleTokenVerifier,
|
||||
)
|
||||
|
||||
|
||||
GOOGLE_SCOPE_ALIASES: dict[str, str] = {
|
||||
"email": "https://www.googleapis.com/auth/userinfo.email",
|
||||
"profile": "https://www.googleapis.com/auth/userinfo.profile",
|
||||
}
|
||||
|
||||
|
||||
def _normalize_google_scope(scope: str) -> str:
|
||||
"""Normalize a Google scope shorthand to its canonical full URI.
|
||||
|
||||
Google accepts shorthand scopes like "email" and "profile" in authorization
|
||||
requests, but returns the full URI form in token responses. This normalizes
|
||||
to the full URI so comparisons work regardless of which form was used.
|
||||
"""
|
||||
return GOOGLE_SCOPE_ALIASES.get(scope, scope)
|
||||
|
||||
|
||||
class GoogleTokenVerifier(TokenVerifier):
|
||||
"""Token verifier for Google OAuth tokens.
|
||||
|
||||
Google OAuth tokens are opaque (not JWTs), so we verify them by calling
|
||||
Google's tokeninfo endpoint with the access token as a query parameter.
|
||||
This returns the OAuth app ID (``aud``), granted scopes, and expiry time.
|
||||
User profile data (name, picture, etc.) is fetched separately from the
|
||||
v2 userinfo endpoint when the token is valid.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
required_scopes: list[str] | None = None,
|
||||
timeout_seconds: int = 10,
|
||||
http_client: httpx.AsyncClient | None = None,
|
||||
):
|
||||
"""Initialize the Google token verifier.
|
||||
|
||||
Args:
|
||||
required_scopes: Required OAuth scopes (e.g., ['openid', 'https://www.googleapis.com/auth/userinfo.email'])
|
||||
timeout_seconds: HTTP request timeout
|
||||
http_client: Optional httpx.AsyncClient for connection pooling. When provided,
|
||||
the client is reused across calls and the caller is responsible for its
|
||||
lifecycle. When None (default), a fresh client is created per call.
|
||||
"""
|
||||
normalized = (
|
||||
[_normalize_google_scope(s) for s in required_scopes]
|
||||
if required_scopes
|
||||
else required_scopes
|
||||
)
|
||||
super().__init__(required_scopes=normalized)
|
||||
self.timeout_seconds = timeout_seconds
|
||||
self._http_client = http_client
|
||||
|
||||
async def verify_token(self, token: str) -> AccessToken | None:
|
||||
"""Verify a Google OAuth token using the tokeninfo endpoint.
|
||||
|
||||
Calls ``https://oauth2.googleapis.com/tokeninfo?access_token=TOKEN``
|
||||
to validate the token and retrieve the OAuth app ID (``aud``), granted
|
||||
scopes, and expiry time. On success, fetches user profile data from
|
||||
the v2 userinfo endpoint to populate name, picture, and locale claims.
|
||||
"""
|
||||
try:
|
||||
async with (
|
||||
contextlib.nullcontext(self._http_client)
|
||||
if self._http_client is not None
|
||||
else httpx.AsyncClient(timeout=self.timeout_seconds)
|
||||
) as client:
|
||||
# Step 1: Verify token via tokeninfo endpoint.
|
||||
# Returns aud (OAuth app ID), scope (space-separated), expires_in, sub, email.
|
||||
response = await client.get(
|
||||
"https://oauth2.googleapis.com/tokeninfo",
|
||||
params={"access_token": token},
|
||||
headers={"User-Agent": "FastMCP-Google-OAuth"},
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.debug(
|
||||
"Google token verification failed: %d",
|
||||
response.status_code,
|
||||
)
|
||||
return None
|
||||
|
||||
token_data = response.json()
|
||||
|
||||
# aud is the OAuth app ID (client_id / audience)
|
||||
aud = token_data.get("aud")
|
||||
if not aud:
|
||||
logger.debug("Google tokeninfo missing 'aud' claim")
|
||||
return None
|
||||
|
||||
# sub is required (unique Google user ID)
|
||||
sub = token_data.get("sub")
|
||||
if not sub:
|
||||
logger.debug("Google tokeninfo missing 'sub' claim")
|
||||
return None
|
||||
|
||||
# Parse scopes directly from the tokeninfo response (space-separated)
|
||||
scope_str = token_data.get("scope", "")
|
||||
token_scopes = scope_str.split() if scope_str else []
|
||||
|
||||
# Check required scopes
|
||||
if self.required_scopes:
|
||||
token_scopes_set = set(token_scopes)
|
||||
required_scopes_set = set(self.required_scopes)
|
||||
if not required_scopes_set.issubset(token_scopes_set):
|
||||
logger.debug(
|
||||
"Google token missing required scopes. Has %d, needs %d",
|
||||
len(token_scopes_set),
|
||||
len(required_scopes_set),
|
||||
)
|
||||
return None
|
||||
|
||||
# Compute expiry from expires_in (seconds until expiry)
|
||||
expires_at: int | None = None
|
||||
expires_in = token_data.get("expires_in")
|
||||
if expires_in is not None:
|
||||
with contextlib.suppress(ValueError, TypeError):
|
||||
expires_at = int(time.time()) + int(expires_in)
|
||||
|
||||
# Step 2: Fetch user profile from v2 userinfo endpoint.
|
||||
# tokeninfo provides auth data; userinfo provides name, picture, locale.
|
||||
user_data: dict = {}
|
||||
try:
|
||||
userinfo_response = await client.get(
|
||||
"https://www.googleapis.com/oauth2/v2/userinfo",
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"User-Agent": "FastMCP-Google-OAuth",
|
||||
},
|
||||
)
|
||||
if userinfo_response.status_code == 200:
|
||||
user_data = userinfo_response.json()
|
||||
except Exception as e:
|
||||
logger.debug("Failed to fetch Google user profile: %s", e)
|
||||
|
||||
access_token = AccessToken(
|
||||
token=token,
|
||||
client_id=sub,
|
||||
scopes=token_scopes,
|
||||
expires_at=expires_at,
|
||||
claims={
|
||||
"sub": sub,
|
||||
"aud": aud,
|
||||
"email": token_data.get("email") or user_data.get("email"),
|
||||
"email_verified": token_data.get("email_verified")
|
||||
or user_data.get("verified_email"),
|
||||
"name": user_data.get("name"),
|
||||
"picture": user_data.get("picture"),
|
||||
"given_name": user_data.get("given_name"),
|
||||
"family_name": user_data.get("family_name"),
|
||||
"locale": user_data.get("locale"),
|
||||
"google_user_data": user_data or None,
|
||||
},
|
||||
)
|
||||
logger.debug("Google token verified successfully")
|
||||
return access_token
|
||||
|
||||
except httpx.RequestError as e:
|
||||
logger.debug("Failed to verify Google token: %s", e)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.debug("Google token verification error: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
class GoogleProvider(OAuthProxy):
|
||||
"""Complete Google OAuth provider for FastMCP.
|
||||
|
||||
This provider makes it trivial to add Google OAuth protection to any
|
||||
FastMCP server. Just provide your Google OAuth app credentials and
|
||||
a base URL, and you're ready to go.
|
||||
|
||||
Features:
|
||||
- Transparent OAuth proxy to Google
|
||||
- Automatic token validation via Google's tokeninfo API
|
||||
- User information extraction from Google APIs
|
||||
- Minimal configuration required
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.google import GoogleProvider
|
||||
|
||||
auth = GoogleProvider(
|
||||
client_id="123456789.apps.googleusercontent.com",
|
||||
client_secret="GOCSPX-abc123...",
|
||||
base_url="https://my-server.com"
|
||||
)
|
||||
|
||||
mcp = FastMCP("My App", auth=auth)
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
client_id: str,
|
||||
client_secret: str | None = None,
|
||||
base_url: AnyHttpUrl | str,
|
||||
resource_base_url: AnyHttpUrl | str | None = None,
|
||||
issuer_url: AnyHttpUrl | str | None = None,
|
||||
redirect_path: str | None = None,
|
||||
required_scopes: list[str] | None = None,
|
||||
valid_scopes: list[str] | None = None,
|
||||
timeout_seconds: int = 10,
|
||||
allowed_client_redirect_uris: list[str] | None = None,
|
||||
client_storage: AsyncKeyValue | None = None,
|
||||
jwt_signing_key: str | bytes | None = None,
|
||||
require_authorization_consent: bool | Literal["remember", "external"] = True,
|
||||
consent_csp_policy: str | None = None,
|
||||
forward_resource: bool = True,
|
||||
extra_authorize_params: dict[str, str] | None = None,
|
||||
http_client: httpx.AsyncClient | None = None,
|
||||
enable_cimd: bool = True,
|
||||
):
|
||||
"""Initialize Google OAuth provider.
|
||||
|
||||
Args:
|
||||
client_id: Google OAuth client ID (e.g., "123456789.apps.googleusercontent.com")
|
||||
client_secret: Google OAuth client secret (e.g., "GOCSPX-abc123...").
|
||||
Optional for PKCE public clients (e.g., native apps). When omitted,
|
||||
jwt_signing_key must be provided.
|
||||
base_url: Public URL where OAuth endpoints will be accessible (includes any mount path)
|
||||
resource_base_url: Optional public base URL for the protected resource metadata
|
||||
and token audience. Defaults to ``base_url``.
|
||||
issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL
|
||||
to avoid 404s during discovery when mounting under a path.
|
||||
redirect_path: Redirect path configured in Google OAuth app (defaults to "/auth/callback")
|
||||
required_scopes: Required Google scopes (defaults to ["openid"]). Common scopes include:
|
||||
- "openid" for OpenID Connect (default)
|
||||
- "https://www.googleapis.com/auth/userinfo.email" for email access
|
||||
- "https://www.googleapis.com/auth/userinfo.profile" for profile info
|
||||
Google scope shorthands like "email" and "profile" are automatically
|
||||
normalized to their full URI forms for token verification.
|
||||
valid_scopes: All scopes that clients are allowed to request, advertised through
|
||||
well-known endpoints. Defaults to required_scopes if not provided. Use this
|
||||
when you want clients to be able to request additional scopes beyond the
|
||||
required minimum. Shorthands are normalized to full URI forms.
|
||||
timeout_seconds: HTTP request timeout for Google API calls (defaults to 10)
|
||||
allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients.
|
||||
If None (default), all URIs are allowed. If empty list, no URIs are allowed.
|
||||
client_storage: Storage backend for OAuth state (client registrations, encrypted tokens).
|
||||
If None, an encrypted file store will be created in the data directory
|
||||
(derived from `platformdirs`).
|
||||
jwt_signing_key: Secret for signing FastMCP JWT tokens (any string or bytes). If bytes are provided,
|
||||
they will be used as is. If a string is provided, it will be derived into a 32-byte key. If not
|
||||
provided, the upstream client secret will be used to derive a 32-byte key using PBKDF2.
|
||||
require_authorization_consent: Whether to require user consent before authorizing clients (default True).
|
||||
When True, users see a consent screen before being redirected to Google.
|
||||
When False, authorization proceeds directly without user confirmation.
|
||||
When "external", the built-in consent screen is skipped but no warning is
|
||||
logged, indicating that consent is handled externally (e.g. by Google's own consent).
|
||||
SECURITY WARNING: Only set to False for local development or testing environments.
|
||||
extra_authorize_params: Additional parameters to forward to Google's authorization endpoint.
|
||||
By default, GoogleProvider sets {"access_type": "offline", "prompt": "consent"} to ensure
|
||||
refresh tokens are returned. You can override these defaults or add additional parameters.
|
||||
Example: {"prompt": "select_account"} to let users choose their Google account.
|
||||
http_client: Optional httpx.AsyncClient for connection pooling in token verification.
|
||||
When provided, the client is reused across verify_token calls and the caller
|
||||
is responsible for its lifecycle. When None (default), a fresh client is created per call.
|
||||
enable_cimd: Enable CIMD (Client ID Metadata Document) support for URL-based
|
||||
client IDs (default True). Set to False to disable.
|
||||
"""
|
||||
# Parse scopes if provided as string
|
||||
# Google requires at least one scope - openid is the minimal OIDC scope
|
||||
required_scopes_final = (
|
||||
parse_scopes(required_scopes) if required_scopes is not None else ["openid"]
|
||||
)
|
||||
|
||||
# Normalize valid_scopes if provided
|
||||
parsed_valid_scopes = (
|
||||
parse_scopes(valid_scopes) if valid_scopes is not None else None
|
||||
)
|
||||
valid_scopes_final = (
|
||||
[_normalize_google_scope(s) for s in parsed_valid_scopes]
|
||||
if parsed_valid_scopes is not None
|
||||
else None
|
||||
)
|
||||
|
||||
# Create Google token verifier
|
||||
# Normalization of shorthand scopes (e.g. "email" -> full URI) happens
|
||||
# inside GoogleTokenVerifier so required_scopes match what Google returns.
|
||||
token_verifier = GoogleTokenVerifier(
|
||||
required_scopes=required_scopes_final,
|
||||
timeout_seconds=timeout_seconds,
|
||||
http_client=http_client,
|
||||
)
|
||||
|
||||
# Set Google-specific defaults for extra authorize params
|
||||
# access_type=offline ensures refresh tokens are returned
|
||||
# prompt=consent forces consent screen to get refresh token (Google only issues on first auth otherwise)
|
||||
google_defaults = {
|
||||
"access_type": "offline",
|
||||
"prompt": "consent",
|
||||
}
|
||||
# User-provided params override defaults
|
||||
if extra_authorize_params:
|
||||
google_defaults.update(extra_authorize_params)
|
||||
extra_authorize_params_final = google_defaults
|
||||
|
||||
# Initialize OAuth proxy with Google endpoints
|
||||
super().__init__(
|
||||
upstream_authorization_endpoint="https://accounts.google.com/o/oauth2/v2/auth",
|
||||
upstream_token_endpoint="https://oauth2.googleapis.com/token",
|
||||
upstream_client_id=client_id,
|
||||
upstream_client_secret=client_secret,
|
||||
token_verifier=token_verifier,
|
||||
base_url=base_url,
|
||||
resource_base_url=resource_base_url,
|
||||
redirect_path=redirect_path,
|
||||
issuer_url=issuer_url or base_url, # Default to base_url if not specified
|
||||
allowed_client_redirect_uris=allowed_client_redirect_uris,
|
||||
client_storage=client_storage,
|
||||
jwt_signing_key=jwt_signing_key,
|
||||
require_authorization_consent=require_authorization_consent,
|
||||
consent_csp_policy=consent_csp_policy,
|
||||
forward_resource=forward_resource,
|
||||
extra_authorize_params=extra_authorize_params_final,
|
||||
valid_scopes=valid_scopes_final,
|
||||
enable_cimd=enable_cimd,
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"Initialized Google OAuth provider for client %s with scopes: %s",
|
||||
client_id,
|
||||
required_scopes_final,
|
||||
)
|
||||
__all__ = ["GoogleProvider", "GoogleTokenVerifier"]
|
||||
|
|
|
|||
|
|
@ -1,74 +1,22 @@
|
|||
"""Keycloak authentication provider for FastMCP."""
|
||||
"""Backward compatibility shim for Keycloak auth provider."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import AnyHttpUrl
|
||||
import warnings
|
||||
|
||||
from fastmcp.server.auth import RemoteAuthProvider, TokenVerifier
|
||||
from fastmcp.server.auth.providers.jwt import JWTVerifier
|
||||
from fastmcp.utilities.auth import parse_scopes
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
from fastmcp import settings
|
||||
from fastmcp.exceptions import FastMCPDeprecationWarning
|
||||
|
||||
logger = get_logger(__name__)
|
||||
if settings.deprecation_warnings:
|
||||
warnings.warn(
|
||||
"fastmcp.server.auth.providers.keycloak is deprecated. "
|
||||
"Import from fastmcp.server.plugins.auth.keycloak.provider instead.",
|
||||
FastMCPDeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
from fastmcp.server.plugins.auth.keycloak.provider import (
|
||||
KeycloakAuthProvider,
|
||||
)
|
||||
|
||||
class KeycloakAuthProvider(RemoteAuthProvider):
|
||||
"""Keycloak authentication provider using Dynamic Client Registration (DCR).
|
||||
|
||||
Requires Keycloak 26.6.0 or later, which includes the fix for DCR compatibility
|
||||
with MCP clients (https://github.com/keycloak/keycloak/pull/45309).
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.keycloak import KeycloakAuthProvider
|
||||
|
||||
auth = KeycloakAuthProvider(
|
||||
realm_url="https://keycloak.example.com/realms/myrealm",
|
||||
base_url="https://my-mcp-server.example.com",
|
||||
)
|
||||
|
||||
mcp = FastMCP("My App", auth=auth)
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
realm_url: AnyHttpUrl | str,
|
||||
base_url: AnyHttpUrl | str,
|
||||
required_scopes: list[str] | str | None = None,
|
||||
audience: str | list[str] | None = None,
|
||||
token_verifier: TokenVerifier | None = None,
|
||||
):
|
||||
"""Initialize the Keycloak auth provider.
|
||||
|
||||
Args:
|
||||
realm_url: Keycloak realm URL (e.g., "https://keycloak.example.com/realms/myrealm")
|
||||
base_url: Public URL of this FastMCP server
|
||||
required_scopes: Scopes to require on incoming tokens. Defaults to
|
||||
["openid"], which ensures the `sub` claim (user identifier) is
|
||||
present in the access token. Override to require additional scopes.
|
||||
audience: Optional audience(s) for JWT validation. Recommended for production.
|
||||
token_verifier: Optional custom token verifier. Defaults to a JWTVerifier
|
||||
configured for Keycloak's JWKS endpoint and issuer.
|
||||
"""
|
||||
self.realm_url = str(realm_url).rstrip("/")
|
||||
parsed_scopes = (
|
||||
parse_scopes(required_scopes) if required_scopes is not None else ["openid"]
|
||||
)
|
||||
|
||||
if token_verifier is None:
|
||||
token_verifier = JWTVerifier(
|
||||
jwks_uri=f"{self.realm_url}/protocol/openid-connect/certs",
|
||||
issuer=self.realm_url,
|
||||
algorithm="RS256",
|
||||
required_scopes=parsed_scopes,
|
||||
audience=audience,
|
||||
)
|
||||
|
||||
super().__init__(
|
||||
token_verifier=token_verifier,
|
||||
authorization_servers=[AnyHttpUrl(self.realm_url)],
|
||||
base_url=AnyHttpUrl(str(base_url).rstrip("/")),
|
||||
)
|
||||
__all__ = ["KeycloakAuthProvider"]
|
||||
|
|
|
|||
|
|
@ -1,180 +1,20 @@
|
|||
"""OCI OIDC provider for FastMCP.
|
||||
"""Backward compatibility shim for OCI auth provider."""
|
||||
|
||||
The pull request for the provider is submitted to fastmcp.
|
||||
from __future__ import annotations
|
||||
|
||||
This module provides OIDC Implementation to integrate MCP servers with OCI.
|
||||
You only need OCI Identity Domain's discovery URL, client ID, client secret, and base URL.
|
||||
import warnings
|
||||
|
||||
Post Authentication, you get OCI IAM domain access token. That is not authorized to invoke OCI control plane.
|
||||
You need to exchange the IAM domain access token for OCI UPST token to invoke OCI control plane APIs.
|
||||
The sample code below has get_oci_signer function that returns OCI TokenExchangeSigner object.
|
||||
You can use the signer object to create OCI service object.
|
||||
from fastmcp import settings
|
||||
from fastmcp.exceptions import FastMCPDeprecationWarning
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.oci import OCIProvider
|
||||
from fastmcp.server.dependencies import get_access_token
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
|
||||
import os
|
||||
|
||||
import oci
|
||||
from oci.auth.signers import TokenExchangeSigner
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Load configuration from environment
|
||||
config_url = os.environ.get("OCI_CONFIG_URL") # OCI IAM Domain OIDC discovery URL
|
||||
client_id = os.environ.get("OCI_CLIENT_ID") # Client ID configured for the OCI IAM Domain Integrated Application
|
||||
client_secret = os.environ.get("OCI_CLIENT_SECRET") # Client secret configured for the OCI IAM Domain Integrated Application
|
||||
iam_guid = os.environ.get("OCI_IAM_GUID") # IAM GUID configured for the OCI IAM Domain
|
||||
|
||||
# Simple OCI OIDC protection
|
||||
auth = OCIProvider(
|
||||
config_url=config_url, # config URL is the OCI IAM Domain OIDC discovery URL
|
||||
client_id=client_id, # This is same as the client ID configured for the OCI IAM Domain Integrated Application
|
||||
client_secret=client_secret, # This is same as the client secret configured for the OCI IAM Domain Integrated Application
|
||||
required_scopes=["openid", "profile", "email"],
|
||||
redirect_path="/auth/callback",
|
||||
base_url="http://localhost:8000",
|
||||
if settings.deprecation_warnings:
|
||||
warnings.warn(
|
||||
"fastmcp.server.auth.providers.oci is deprecated. "
|
||||
"Import from fastmcp.server.plugins.auth.oci.provider instead.",
|
||||
FastMCPDeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
# NOTE: For production use, replace this with a thread-safe cache implementation
|
||||
# such as threading.Lock-protected dict or a proper caching library
|
||||
_global_token_cache = {} # In memory cache for OCI session token signer
|
||||
from fastmcp.server.plugins.auth.oci.provider import OCIProvider
|
||||
|
||||
def get_oci_signer() -> TokenExchangeSigner:
|
||||
|
||||
authntoken = get_access_token()
|
||||
tokenID = authntoken.claims.get("jti")
|
||||
token = authntoken.token
|
||||
|
||||
# Check if the signer exists for the token ID in memory cache
|
||||
cached_signer = _global_token_cache.get(tokenID)
|
||||
logger.debug(f"Global cached signer: {cached_signer}")
|
||||
if cached_signer:
|
||||
logger.debug(f"Using globally cached signer for token ID: {tokenID}")
|
||||
return cached_signer
|
||||
|
||||
# If the signer is not yet created for the token then create new OCI signer object
|
||||
logger.debug(f"Creating new signer for token ID: {tokenID}")
|
||||
signer = TokenExchangeSigner(
|
||||
jwt_or_func=token,
|
||||
oci_domain_id=iam_guid.split(".")[0] if iam_guid else None, # This is same as IAM GUID configured for the OCI IAM Domain
|
||||
client_id=client_id, # This is same as the client ID configured for the OCI IAM Domain Integrated Application
|
||||
client_secret=client_secret, # This is same as the client secret configured for the OCI IAM Domain Integrated Application
|
||||
)
|
||||
logger.debug(f"Signer {signer} created for token ID: {tokenID}")
|
||||
|
||||
#Cache the signer object in memory cache
|
||||
_global_token_cache[tokenID] = signer
|
||||
logger.debug(f"Signer cached for token ID: {tokenID}")
|
||||
|
||||
return signer
|
||||
|
||||
mcp = FastMCP("My Protected Server", auth=auth)
|
||||
```
|
||||
"""
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from key_value.aio.protocols import AsyncKeyValue
|
||||
from pydantic import AnyHttpUrl
|
||||
|
||||
from fastmcp.server.auth.oidc_proxy import OIDCProxy
|
||||
from fastmcp.utilities.auth import parse_scopes
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class OCIProvider(OIDCProxy):
|
||||
"""An OCI IAM Domain provider implementation for FastMCP.
|
||||
|
||||
This provider is a complete OCI integration that's ready to use with
|
||||
just the configuration URL, client ID, client secret, and base URL.
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.oci import OCIProvider
|
||||
|
||||
import os
|
||||
|
||||
# Load configuration from environment
|
||||
auth = OCIProvider(
|
||||
config_url=os.environ.get("OCI_CONFIG_URL"), # OCI IAM Domain OIDC discovery URL
|
||||
client_id=os.environ.get("OCI_CLIENT_ID"), # Client ID configured for the OCI IAM Domain Integrated Application
|
||||
client_secret=os.environ.get("OCI_CLIENT_SECRET"), # Client secret configured for the OCI IAM Domain Integrated Application
|
||||
base_url="http://localhost:8000",
|
||||
required_scopes=["openid", "profile", "email"],
|
||||
redirect_path="/auth/callback",
|
||||
)
|
||||
|
||||
mcp = FastMCP("My Protected Server", auth=auth)
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
config_url: AnyHttpUrl | str,
|
||||
client_id: str,
|
||||
client_secret: str,
|
||||
base_url: AnyHttpUrl | str,
|
||||
resource_base_url: AnyHttpUrl | str | None = None,
|
||||
audience: str | None = None,
|
||||
issuer_url: AnyHttpUrl | str | None = None,
|
||||
required_scopes: list[str] | None = None,
|
||||
redirect_path: str | None = None,
|
||||
allowed_client_redirect_uris: list[str] | None = None,
|
||||
client_storage: AsyncKeyValue | None = None,
|
||||
jwt_signing_key: str | bytes | None = None,
|
||||
require_authorization_consent: bool | Literal["remember", "external"] = True,
|
||||
consent_csp_policy: str | None = None,
|
||||
forward_resource: bool = True,
|
||||
) -> None:
|
||||
"""Initialize OCI OIDC provider.
|
||||
|
||||
Args:
|
||||
config_url: OCI OIDC Discovery URL
|
||||
client_id: OCI IAM Domain Integrated Application client id
|
||||
client_secret: OCI Integrated Application client secret
|
||||
base_url: Public URL where OIDC endpoints will be accessible (includes any mount path)
|
||||
resource_base_url: Optional public base URL for the protected resource metadata
|
||||
and token audience. Defaults to ``base_url``.
|
||||
audience: OCI API audience (optional)
|
||||
issuer_url: Issuer URL for OCI IAM Domain metadata. This will override issuer URL from the discovery URL.
|
||||
required_scopes: Required OCI scopes (defaults to ["openid"])
|
||||
redirect_path: Redirect path configured in OCI IAM Domain Integrated Application. The default is "/auth/callback".
|
||||
allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients.
|
||||
"""
|
||||
# Parse scopes if provided as string
|
||||
oci_required_scopes = (
|
||||
parse_scopes(required_scopes) if required_scopes is not None else ["openid"]
|
||||
)
|
||||
|
||||
super().__init__(
|
||||
config_url=config_url,
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
audience=audience,
|
||||
base_url=base_url,
|
||||
resource_base_url=resource_base_url,
|
||||
issuer_url=issuer_url,
|
||||
redirect_path=redirect_path,
|
||||
required_scopes=oci_required_scopes,
|
||||
allowed_client_redirect_uris=allowed_client_redirect_uris,
|
||||
client_storage=client_storage,
|
||||
jwt_signing_key=jwt_signing_key,
|
||||
require_authorization_consent=require_authorization_consent,
|
||||
consent_csp_policy=consent_csp_policy,
|
||||
forward_resource=forward_resource,
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"Initialized OCI OAuth provider for client %s with scopes: %s",
|
||||
client_id,
|
||||
oci_required_scopes,
|
||||
)
|
||||
__all__ = ["OCIProvider"]
|
||||
|
|
|
|||
|
|
@ -1,234 +1,23 @@
|
|||
"""PropelAuth authentication provider for FastMCP.
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.propelauth import PropelAuthProvider
|
||||
|
||||
auth = PropelAuthProvider(
|
||||
auth_url="https://auth.yourdomain.com",
|
||||
introspection_client_id="your-client-id",
|
||||
introspection_client_secret="your-client-secret",
|
||||
base_url="https://your-fastmcp-server.com",
|
||||
required_scopes=["read:user_data"],
|
||||
)
|
||||
|
||||
mcp = FastMCP("My App", auth=auth)
|
||||
```
|
||||
"""
|
||||
"""Backward compatibility shim for PropelAuth auth provider."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TypedDict
|
||||
import warnings
|
||||
|
||||
import httpx
|
||||
from pydantic import AnyHttpUrl, SecretStr
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.routing import Route
|
||||
from fastmcp import settings
|
||||
from fastmcp.exceptions import FastMCPDeprecationWarning
|
||||
|
||||
from fastmcp.server.auth import AccessToken, RemoteAuthProvider
|
||||
from fastmcp.server.auth.providers.introspection import IntrospectionTokenVerifier
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
if settings.deprecation_warnings:
|
||||
warnings.warn(
|
||||
"fastmcp.server.auth.providers.propelauth is deprecated. "
|
||||
"Import from fastmcp.server.plugins.auth.propelauth.provider instead.",
|
||||
FastMCPDeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
from fastmcp.server.plugins.auth.propelauth.provider import (
|
||||
PropelAuthProvider,
|
||||
PropelAuthTokenIntrospectionOverrides,
|
||||
)
|
||||
|
||||
|
||||
class PropelAuthTokenIntrospectionOverrides(TypedDict, total=False):
|
||||
timeout_seconds: int
|
||||
cache_ttl_seconds: int | None
|
||||
max_cache_size: int | None
|
||||
http_client: httpx.AsyncClient | None
|
||||
|
||||
|
||||
class PropelAuthProvider(RemoteAuthProvider):
|
||||
"""PropelAuth resource server provider using OAuth 2.1 token introspection.
|
||||
|
||||
This provider validates access tokens via PropelAuth's introspection endpoint
|
||||
and forwards authorization server metadata for OAuth discovery.
|
||||
|
||||
Setup:
|
||||
1. Enable MCP authentication in the PropelAuth Dashboard
|
||||
2. Configure scopes on the MCP page
|
||||
3. Select which redirect URIs to enable by picking which clients you support
|
||||
4. Generate introspection credentials (Client ID + Client Secret)
|
||||
|
||||
For detailed setup instructions, see:
|
||||
https://docs.propelauth.com/mcp-authentication/overview
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.propelauth import PropelAuthProvider
|
||||
|
||||
auth = PropelAuthProvider(
|
||||
auth_url="https://auth.yourdomain.com",
|
||||
introspection_client_id="your-client-id",
|
||||
introspection_client_secret="your-client-secret",
|
||||
base_url="https://your-fastmcp-server.com",
|
||||
required_scopes=["read:user_data"],
|
||||
)
|
||||
|
||||
mcp = FastMCP("My App", auth=auth)
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
auth_url: AnyHttpUrl | str,
|
||||
introspection_client_id: str,
|
||||
introspection_client_secret: str | SecretStr,
|
||||
base_url: AnyHttpUrl | str,
|
||||
required_scopes: list[str] | None = None,
|
||||
scopes_supported: list[str] | None = None,
|
||||
resource_name: str | None = None,
|
||||
resource_documentation: AnyHttpUrl | None = None,
|
||||
resource: AnyHttpUrl | str | None = None,
|
||||
token_introspection_overrides: (
|
||||
PropelAuthTokenIntrospectionOverrides | None
|
||||
) = None,
|
||||
):
|
||||
"""Initialize PropelAuth provider.
|
||||
|
||||
Args:
|
||||
auth_url: Your PropelAuth Auth URL (from the Backend Integration page)
|
||||
introspection_client_id: Introspection Client ID from the PropelAuth Dashboard
|
||||
introspection_client_secret: Introspection Client Secret from the PropelAuth Dashboard
|
||||
base_url: Public URL of this FastMCP server
|
||||
required_scopes: Optional list of scopes that must be present in tokens
|
||||
scopes_supported: Optional list of scopes to advertise in OAuth metadata.
|
||||
If None, uses required_scopes. Use this when the scopes clients should
|
||||
request differ from the scopes enforced on tokens.
|
||||
resource_name: Optional name for the protected resource metadata.
|
||||
resource_documentation: Optional documentation URL for the protected resource.
|
||||
resource: Optional resource URI (RFC 8707) identifying this MCP server.
|
||||
Use this when multiple MCP servers share the same PropelAuth
|
||||
authorization server (e.g. ``resource="https://api.example.com/mcp"``),
|
||||
so only tokens intended for this MCP server are accepted.
|
||||
token_introspection_overrides: Optional overrides for the underlying
|
||||
IntrospectionTokenVerifier (timeout, caching, http_client)
|
||||
"""
|
||||
normalized_auth_url = str(auth_url).rstrip("/")
|
||||
introspection_url = f"{normalized_auth_url}/oauth/2.1/introspect"
|
||||
authorization_server_url = AnyHttpUrl(f"{normalized_auth_url}/oauth/2.1")
|
||||
|
||||
if resource is None:
|
||||
self._resource = None
|
||||
logger.debug(
|
||||
"PropelAuthProvider: no resource configured, audience checking disabled"
|
||||
)
|
||||
else:
|
||||
self._resource = str(resource)
|
||||
|
||||
token_verifier = self._create_token_verifier(
|
||||
introspection_url=introspection_url,
|
||||
client_id=introspection_client_id,
|
||||
client_secret=introspection_client_secret,
|
||||
required_scopes=required_scopes,
|
||||
introspection_overrides=token_introspection_overrides,
|
||||
)
|
||||
|
||||
self._normalized_auth_url = normalized_auth_url
|
||||
super().__init__(
|
||||
token_verifier=token_verifier,
|
||||
authorization_servers=[authorization_server_url],
|
||||
base_url=base_url,
|
||||
scopes_supported=scopes_supported,
|
||||
resource_name=resource_name,
|
||||
resource_documentation=resource_documentation,
|
||||
)
|
||||
|
||||
def get_routes(
|
||||
self,
|
||||
mcp_path: str | None = None,
|
||||
) -> list[Route]:
|
||||
"""Get routes for this provider.
|
||||
|
||||
Includes the standard routes from the RemoteAuthProvider (protected resource metadata routes (RFC 9728)),
|
||||
and creates an authorization server metadata route that forwards to PropelAuth's route
|
||||
|
||||
Args:
|
||||
mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp")
|
||||
This is used to advertise the resource URL in metadata.
|
||||
"""
|
||||
routes = super().get_routes(mcp_path)
|
||||
|
||||
async def oauth_authorization_server_metadata(request):
|
||||
"""Forward PropelAuth OAuth authorization server metadata"""
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
f"{self._normalized_auth_url}/.well-known/oauth-authorization-server/oauth/2.1"
|
||||
)
|
||||
response.raise_for_status()
|
||||
metadata = response.json()
|
||||
return JSONResponse(metadata)
|
||||
except Exception as e:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "server_error",
|
||||
"error_description": f"Failed to fetch PropelAuth metadata: {e}",
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
routes.append(
|
||||
Route(
|
||||
"/.well-known/oauth-authorization-server",
|
||||
endpoint=oauth_authorization_server_metadata,
|
||||
methods=["GET"],
|
||||
)
|
||||
)
|
||||
|
||||
return routes
|
||||
|
||||
async def verify_token(self, token: str) -> AccessToken | None:
|
||||
"""Verify token and check the ``aud`` claim against the configured resource."""
|
||||
result = await super().verify_token(token)
|
||||
if result is None or self._resource is None:
|
||||
return result
|
||||
|
||||
aud = result.claims.get("aud")
|
||||
if aud != self._resource:
|
||||
logger.debug(
|
||||
"PropelAuthProvider: token audience %r does not match resource %s",
|
||||
aud,
|
||||
self._resource,
|
||||
)
|
||||
return None
|
||||
|
||||
return result
|
||||
|
||||
def _create_token_verifier(
|
||||
self,
|
||||
introspection_url: str,
|
||||
client_id: str,
|
||||
client_secret: str | SecretStr,
|
||||
required_scopes: list[str] | None,
|
||||
introspection_overrides: PropelAuthTokenIntrospectionOverrides | None,
|
||||
) -> IntrospectionTokenVerifier:
|
||||
# Being defensive here, check for only the fields we are expecting
|
||||
safe_overrides: PropelAuthTokenIntrospectionOverrides = {}
|
||||
if introspection_overrides is not None:
|
||||
if "timeout_seconds" in introspection_overrides:
|
||||
safe_overrides["timeout_seconds"] = introspection_overrides[
|
||||
"timeout_seconds"
|
||||
]
|
||||
if "cache_ttl_seconds" in introspection_overrides:
|
||||
safe_overrides["cache_ttl_seconds"] = introspection_overrides[
|
||||
"cache_ttl_seconds"
|
||||
]
|
||||
if "max_cache_size" in introspection_overrides:
|
||||
safe_overrides["max_cache_size"] = introspection_overrides[
|
||||
"max_cache_size"
|
||||
]
|
||||
if "http_client" in introspection_overrides:
|
||||
safe_overrides["http_client"] = introspection_overrides["http_client"]
|
||||
|
||||
return IntrospectionTokenVerifier(
|
||||
introspection_url=introspection_url,
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
required_scopes=required_scopes,
|
||||
**safe_overrides,
|
||||
)
|
||||
__all__ = ["PropelAuthProvider", "PropelAuthTokenIntrospectionOverrides"]
|
||||
|
|
|
|||
|
|
@ -1,212 +1,20 @@
|
|||
"""Scalekit authentication provider for FastMCP.
|
||||
|
||||
This module provides ScalekitProvider - a complete authentication solution that integrates
|
||||
with Scalekit's OAuth 2.1 and OpenID Connect services, supporting Resource Server
|
||||
authentication for seamless MCP client authentication.
|
||||
"""
|
||||
"""Backward compatibility shim for Scalekit auth provider."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
from pydantic import AnyHttpUrl
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.routing import Route
|
||||
import warnings
|
||||
|
||||
from fastmcp.server.auth import RemoteAuthProvider, TokenVerifier
|
||||
from fastmcp.server.auth.providers.jwt import JWTVerifier
|
||||
from fastmcp.utilities.auth import parse_scopes
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
from fastmcp import settings
|
||||
from fastmcp.exceptions import FastMCPDeprecationWarning
|
||||
|
||||
logger = get_logger(__name__)
|
||||
if settings.deprecation_warnings:
|
||||
warnings.warn(
|
||||
"fastmcp.server.auth.providers.scalekit is deprecated. "
|
||||
"Import from fastmcp.server.plugins.auth.scalekit.provider instead.",
|
||||
FastMCPDeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
from fastmcp.server.plugins.auth.scalekit.provider import ScalekitProvider
|
||||
|
||||
class ScalekitProvider(RemoteAuthProvider):
|
||||
"""Scalekit resource server provider for OAuth 2.1 authentication.
|
||||
|
||||
This provider implements Scalekit integration using resource server pattern.
|
||||
FastMCP acts as a protected resource server that validates access tokens issued
|
||||
by Scalekit's authorization server.
|
||||
|
||||
IMPORTANT SETUP REQUIREMENTS:
|
||||
|
||||
1. Create an MCP Server in Scalekit Dashboard:
|
||||
- Go to your [Scalekit Dashboard](https://app.scalekit.com/)
|
||||
- Navigate to MCP Servers section
|
||||
- Register a new MCP Server with appropriate scopes
|
||||
- Ensure the Resource Identifier matches exactly what you configure as MCP URL
|
||||
- Note the Resource ID
|
||||
|
||||
2. Environment Configuration:
|
||||
- Set SCALEKIT_ENVIRONMENT_URL (e.g., https://your-env.scalekit.com)
|
||||
- Set SCALEKIT_RESOURCE_ID from your created resource
|
||||
- Set BASE_URL to your FastMCP server's public URL
|
||||
|
||||
For detailed setup instructions, see:
|
||||
https://docs.scalekit.com/mcp/overview/
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastmcp.server.auth.providers.scalekit import ScalekitProvider
|
||||
|
||||
# Create Scalekit resource server provider
|
||||
scalekit_auth = ScalekitProvider(
|
||||
environment_url="https://your-env.scalekit.com",
|
||||
resource_id="sk_resource_...",
|
||||
base_url="https://your-fastmcp-server.com",
|
||||
)
|
||||
|
||||
# Use with FastMCP
|
||||
mcp = FastMCP("My App", auth=scalekit_auth)
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
environment_url: AnyHttpUrl | str,
|
||||
resource_id: str,
|
||||
base_url: AnyHttpUrl | str | None = None,
|
||||
mcp_url: AnyHttpUrl | str | None = None,
|
||||
client_id: str | None = None,
|
||||
required_scopes: list[str] | None = None,
|
||||
scopes_supported: list[str] | None = None,
|
||||
resource_name: str | None = None,
|
||||
resource_documentation: AnyHttpUrl | None = None,
|
||||
token_verifier: TokenVerifier | None = None,
|
||||
):
|
||||
"""Initialize Scalekit resource server provider.
|
||||
|
||||
Args:
|
||||
environment_url: Your Scalekit environment URL (e.g., "https://your-env.scalekit.com")
|
||||
resource_id: Your Scalekit resource ID
|
||||
base_url: Public URL of this FastMCP server (or use mcp_url for backwards compatibility)
|
||||
mcp_url: Deprecated alias for base_url. Will be removed in a future release.
|
||||
client_id: Deprecated parameter, no longer required. Will be removed in a future release.
|
||||
required_scopes: Optional list of scopes that must be present in tokens
|
||||
scopes_supported: Optional list of scopes to advertise in OAuth metadata.
|
||||
If None, uses required_scopes. Use this when the scopes clients should
|
||||
request differ from the scopes enforced on tokens.
|
||||
resource_name: Optional name for the protected resource metadata.
|
||||
resource_documentation: Optional documentation URL for the protected resource.
|
||||
token_verifier: Optional token verifier. If None, creates JWT verifier for Scalekit
|
||||
"""
|
||||
# Resolve base_url from mcp_url if needed (backwards compatibility)
|
||||
resolved_base_url = base_url or mcp_url
|
||||
if not resolved_base_url:
|
||||
raise ValueError("Either base_url or mcp_url must be provided")
|
||||
|
||||
if mcp_url is not None:
|
||||
logger.warning(
|
||||
"ScalekitProvider parameter 'mcp_url' is deprecated and will be removed in a future release. "
|
||||
"Rename it to 'base_url'."
|
||||
)
|
||||
|
||||
if client_id is not None:
|
||||
logger.warning(
|
||||
"ScalekitProvider no longer requires 'client_id'. The parameter is accepted only for backward "
|
||||
"compatibility and will be removed in a future release."
|
||||
)
|
||||
|
||||
self.environment_url = str(environment_url).rstrip("/")
|
||||
self.resource_id = resource_id
|
||||
parsed_scopes = (
|
||||
parse_scopes(required_scopes) if required_scopes is not None else []
|
||||
)
|
||||
self.required_scopes = parsed_scopes
|
||||
base_url_value = str(resolved_base_url)
|
||||
|
||||
logger.debug(
|
||||
"Initializing ScalekitProvider: environment_url=%s resource_id=%s base_url=%s required_scopes=%s",
|
||||
self.environment_url,
|
||||
self.resource_id,
|
||||
base_url_value,
|
||||
self.required_scopes,
|
||||
)
|
||||
|
||||
# Create default JWT verifier if none provided
|
||||
if token_verifier is None:
|
||||
logger.debug(
|
||||
"Creating default JWTVerifier for Scalekit: jwks_uri=%s issuer=%s required_scopes=%s",
|
||||
f"{self.environment_url}/keys",
|
||||
self.environment_url,
|
||||
self.required_scopes,
|
||||
)
|
||||
token_verifier = JWTVerifier(
|
||||
jwks_uri=f"{self.environment_url}/keys",
|
||||
issuer=self.environment_url,
|
||||
algorithm="RS256",
|
||||
audience=self.resource_id,
|
||||
required_scopes=self.required_scopes or None,
|
||||
)
|
||||
else:
|
||||
logger.debug("Using custom token verifier for ScalekitProvider")
|
||||
|
||||
# Initialize RemoteAuthProvider with Scalekit as the authorization server
|
||||
super().__init__(
|
||||
token_verifier=token_verifier,
|
||||
authorization_servers=[
|
||||
AnyHttpUrl(f"{self.environment_url}/resources/{self.resource_id}")
|
||||
],
|
||||
base_url=base_url_value,
|
||||
scopes_supported=scopes_supported,
|
||||
resource_name=resource_name,
|
||||
resource_documentation=resource_documentation,
|
||||
)
|
||||
|
||||
def get_routes(
|
||||
self,
|
||||
mcp_path: str | None = None,
|
||||
) -> list[Route]:
|
||||
"""Get OAuth routes including Scalekit authorization server metadata forwarding.
|
||||
|
||||
This returns the standard protected resource routes plus an authorization server
|
||||
metadata endpoint that forwards Scalekit's OAuth metadata to clients.
|
||||
|
||||
Args:
|
||||
mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp")
|
||||
This is used to advertise the resource URL in metadata.
|
||||
"""
|
||||
# Get the standard protected resource routes from RemoteAuthProvider
|
||||
routes = super().get_routes(mcp_path)
|
||||
logger.debug(
|
||||
"Preparing Scalekit metadata routes: mcp_path=%s resource_id=%s",
|
||||
mcp_path,
|
||||
self.resource_id,
|
||||
)
|
||||
|
||||
async def oauth_authorization_server_metadata(request):
|
||||
"""Forward Scalekit OAuth authorization server metadata with FastMCP customizations."""
|
||||
try:
|
||||
metadata_url = f"{self.environment_url}/.well-known/oauth-authorization-server/resources/{self.resource_id}"
|
||||
logger.debug(
|
||||
"Fetching Scalekit OAuth metadata: metadata_url=%s", metadata_url
|
||||
)
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(metadata_url)
|
||||
response.raise_for_status()
|
||||
metadata = response.json()
|
||||
logger.debug(
|
||||
"Scalekit metadata fetched successfully: metadata_keys=%s",
|
||||
list(metadata.keys()),
|
||||
)
|
||||
return JSONResponse(metadata)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch Scalekit metadata: {e}")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "server_error",
|
||||
"error_description": f"Failed to fetch Scalekit metadata: {e}",
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
# Add Scalekit authorization server metadata forwarding
|
||||
routes.append(
|
||||
Route(
|
||||
"/.well-known/oauth-authorization-server",
|
||||
endpoint=oauth_authorization_server_metadata,
|
||||
methods=["GET"],
|
||||
)
|
||||
)
|
||||
|
||||
return routes
|
||||
__all__ = ["ScalekitProvider"]
|
||||
|
|
|
|||
|
|
@ -1,181 +1,20 @@
|
|||
"""Supabase authentication provider for FastMCP.
|
||||
|
||||
This module provides SupabaseProvider - a complete authentication solution that integrates
|
||||
with Supabase Auth's JWT verification, supporting Dynamic Client Registration (DCR)
|
||||
for seamless MCP client authentication.
|
||||
"""
|
||||
"""Backward compatibility shim for Supabase auth provider."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
import warnings
|
||||
|
||||
import httpx
|
||||
from pydantic import AnyHttpUrl
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.routing import Route
|
||||
from fastmcp import settings
|
||||
from fastmcp.exceptions import FastMCPDeprecationWarning
|
||||
|
||||
from fastmcp.server.auth import RemoteAuthProvider, TokenVerifier
|
||||
from fastmcp.server.auth.providers.jwt import JWTVerifier
|
||||
from fastmcp.utilities.auth import parse_scopes
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
if settings.deprecation_warnings:
|
||||
warnings.warn(
|
||||
"fastmcp.server.auth.providers.supabase is deprecated. "
|
||||
"Import from fastmcp.server.plugins.auth.supabase.provider instead.",
|
||||
FastMCPDeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
from fastmcp.server.plugins.auth.supabase.provider import SupabaseProvider
|
||||
|
||||
|
||||
class SupabaseProvider(RemoteAuthProvider):
|
||||
"""Supabase metadata provider for DCR (Dynamic Client Registration).
|
||||
|
||||
This provider implements Supabase Auth integration using metadata forwarding.
|
||||
This approach allows Supabase to handle the OAuth flow directly while FastMCP acts
|
||||
as a resource server, verifying JWTs issued by Supabase Auth.
|
||||
|
||||
IMPORTANT SETUP REQUIREMENTS:
|
||||
|
||||
1. Supabase Project Setup:
|
||||
- Create a Supabase project at https://supabase.com
|
||||
- Note your project URL (e.g., "https://abc123.supabase.co")
|
||||
- Configure your JWT algorithm in Supabase Auth settings (RS256 or ES256)
|
||||
- Asymmetric keys (RS256/ES256) are recommended for production
|
||||
|
||||
2. JWT Verification:
|
||||
- FastMCP verifies JWTs using the JWKS endpoint at {project_url}{auth_route}/.well-known/jwks.json
|
||||
- JWTs are issued by {project_url}{auth_route}
|
||||
- Default auth_route is "/auth/v1" (can be customized for self-hosted setups)
|
||||
- Tokens are cached for up to 10 minutes by Supabase's edge servers
|
||||
- Algorithm must match your Supabase Auth configuration
|
||||
|
||||
3. Authorization:
|
||||
- Supabase uses Row Level Security (RLS) policies for database authorization
|
||||
- OAuth-level scopes are an upcoming feature in Supabase Auth
|
||||
- Both approaches will be supported once scope handling is available
|
||||
|
||||
For detailed setup instructions, see:
|
||||
https://supabase.com/docs/guides/auth/jwts
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastmcp.server.auth.providers.supabase import SupabaseProvider
|
||||
|
||||
# Create Supabase metadata provider (JWT verifier created automatically)
|
||||
supabase_auth = SupabaseProvider(
|
||||
project_url="https://abc123.supabase.co",
|
||||
base_url="https://your-fastmcp-server.com",
|
||||
algorithm="ES256", # Match your Supabase Auth configuration
|
||||
)
|
||||
|
||||
# Use with FastMCP
|
||||
mcp = FastMCP("My App", auth=supabase_auth)
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
project_url: AnyHttpUrl | str,
|
||||
base_url: AnyHttpUrl | str,
|
||||
auth_route: str = "/auth/v1",
|
||||
algorithm: Literal["RS256", "ES256"] = "ES256",
|
||||
required_scopes: list[str] | None = None,
|
||||
scopes_supported: list[str] | None = None,
|
||||
resource_name: str | None = None,
|
||||
resource_documentation: AnyHttpUrl | None = None,
|
||||
token_verifier: TokenVerifier | None = None,
|
||||
):
|
||||
"""Initialize Supabase metadata provider.
|
||||
|
||||
Args:
|
||||
project_url: Your Supabase project URL (e.g., "https://abc123.supabase.co")
|
||||
base_url: Public URL of this FastMCP server
|
||||
auth_route: Supabase Auth route. Defaults to "/auth/v1". Can be customized
|
||||
for self-hosted Supabase Auth setups using custom routes.
|
||||
algorithm: JWT signing algorithm (RS256 or ES256). Must match your
|
||||
Supabase Auth configuration. Defaults to ES256.
|
||||
required_scopes: Optional list of scopes to require for all requests.
|
||||
Note: Supabase currently uses RLS policies for authorization. OAuth-level
|
||||
scopes are an upcoming feature.
|
||||
scopes_supported: Optional list of scopes to advertise in OAuth metadata.
|
||||
If None, uses required_scopes. Use this when the scopes clients should
|
||||
request differ from the scopes enforced on tokens.
|
||||
resource_name: Optional name for the protected resource metadata.
|
||||
resource_documentation: Optional documentation URL for the protected resource.
|
||||
token_verifier: Optional token verifier. If None, creates JWT verifier for Supabase
|
||||
"""
|
||||
self.project_url = str(project_url).rstrip("/")
|
||||
self.base_url = AnyHttpUrl(str(base_url).rstrip("/"))
|
||||
self.auth_route = auth_route.strip("/")
|
||||
|
||||
# Parse scopes if provided as string
|
||||
parsed_scopes = (
|
||||
parse_scopes(required_scopes) if required_scopes is not None else None
|
||||
)
|
||||
|
||||
# Create default JWT verifier if none provided
|
||||
if token_verifier is None:
|
||||
logger.warning(
|
||||
"SupabaseProvider cannot validate token audience for the specific resource "
|
||||
"because Supabase Auth does not support RFC 8707 resource indicators. "
|
||||
"This may leave the server vulnerable to cross-server token replay."
|
||||
)
|
||||
token_verifier = JWTVerifier(
|
||||
jwks_uri=f"{self.project_url}/{self.auth_route}/.well-known/jwks.json",
|
||||
issuer=f"{self.project_url}/{self.auth_route}",
|
||||
algorithm=algorithm,
|
||||
audience="authenticated",
|
||||
required_scopes=parsed_scopes,
|
||||
)
|
||||
|
||||
# Initialize RemoteAuthProvider with Supabase as the authorization server
|
||||
super().__init__(
|
||||
token_verifier=token_verifier,
|
||||
authorization_servers=[AnyHttpUrl(f"{self.project_url}/{self.auth_route}")],
|
||||
base_url=self.base_url,
|
||||
scopes_supported=scopes_supported,
|
||||
resource_name=resource_name,
|
||||
resource_documentation=resource_documentation,
|
||||
)
|
||||
|
||||
def get_routes(
|
||||
self,
|
||||
mcp_path: str | None = None,
|
||||
) -> list[Route]:
|
||||
"""Get OAuth routes including Supabase authorization server metadata forwarding.
|
||||
|
||||
This returns the standard protected resource routes plus an authorization server
|
||||
metadata endpoint that forwards Supabase's OAuth metadata to clients.
|
||||
|
||||
Args:
|
||||
mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp")
|
||||
This is used to advertise the resource URL in metadata.
|
||||
"""
|
||||
# Get the standard protected resource routes from RemoteAuthProvider
|
||||
routes = super().get_routes(mcp_path)
|
||||
|
||||
async def oauth_authorization_server_metadata(request):
|
||||
"""Forward Supabase OAuth authorization server metadata with FastMCP customizations."""
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
f"{self.project_url}/{self.auth_route}/.well-known/oauth-authorization-server"
|
||||
)
|
||||
response.raise_for_status()
|
||||
metadata = response.json()
|
||||
return JSONResponse(metadata)
|
||||
except Exception as e:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "server_error",
|
||||
"error_description": f"Failed to fetch Supabase metadata: {e}",
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
# Add Supabase authorization server metadata forwarding
|
||||
routes.append(
|
||||
Route(
|
||||
"/.well-known/oauth-authorization-server",
|
||||
endpoint=oauth_authorization_server_metadata,
|
||||
methods=["GET"],
|
||||
)
|
||||
)
|
||||
|
||||
return routes
|
||||
__all__ = ["SupabaseProvider"]
|
||||
|
|
|
|||
|
|
@ -1,428 +1,25 @@
|
|||
"""WorkOS authentication providers for FastMCP.
|
||||
|
||||
This module provides two WorkOS authentication strategies:
|
||||
|
||||
1. WorkOSProvider - OAuth proxy for WorkOS Connect applications (non-DCR)
|
||||
2. AuthKitProvider - DCR-compliant provider for WorkOS AuthKit
|
||||
|
||||
Choose based on your WorkOS setup and authentication requirements.
|
||||
"""
|
||||
"""Backward compatibility shim for WorkOS auth providers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
from typing import Literal
|
||||
import warnings
|
||||
|
||||
import httpx
|
||||
from key_value.aio.protocols import AsyncKeyValue
|
||||
from pydantic import AnyHttpUrl
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.routing import Route
|
||||
from fastmcp import settings
|
||||
from fastmcp.exceptions import FastMCPDeprecationWarning
|
||||
|
||||
from fastmcp.server.auth import AccessToken, RemoteAuthProvider, TokenVerifier
|
||||
from fastmcp.server.auth.oauth_proxy import OAuthProxy
|
||||
from fastmcp.server.auth.providers.jwt import JWTVerifier
|
||||
from fastmcp.utilities.auth import parse_scopes
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
if settings.deprecation_warnings:
|
||||
warnings.warn(
|
||||
"fastmcp.server.auth.providers.workos is deprecated. "
|
||||
"Import from fastmcp.server.plugins.auth.workos.provider or "
|
||||
"fastmcp.server.plugins.auth.authkit.provider instead.",
|
||||
FastMCPDeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
from fastmcp.server.plugins.auth.authkit.provider import AuthKitProvider
|
||||
from fastmcp.server.plugins.auth.workos.provider import (
|
||||
WorkOSProvider,
|
||||
WorkOSTokenVerifier,
|
||||
)
|
||||
|
||||
|
||||
class WorkOSTokenVerifier(TokenVerifier):
|
||||
"""Token verifier for WorkOS OAuth tokens.
|
||||
|
||||
WorkOS AuthKit tokens are opaque, so we verify them by calling
|
||||
the /oauth2/userinfo endpoint to check validity and get user info.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
authkit_domain: str,
|
||||
required_scopes: list[str] | None = None,
|
||||
timeout_seconds: int = 10,
|
||||
http_client: httpx.AsyncClient | None = None,
|
||||
):
|
||||
"""Initialize the WorkOS token verifier.
|
||||
|
||||
Args:
|
||||
authkit_domain: WorkOS AuthKit domain (e.g., "https://your-app.authkit.app")
|
||||
required_scopes: Required OAuth scopes
|
||||
timeout_seconds: HTTP request timeout
|
||||
http_client: Optional httpx.AsyncClient for connection pooling. When provided,
|
||||
the client is reused across calls and the caller is responsible for its
|
||||
lifecycle. When None (default), a fresh client is created per call.
|
||||
"""
|
||||
super().__init__(required_scopes=required_scopes)
|
||||
self.authkit_domain = authkit_domain.rstrip("/")
|
||||
self.timeout_seconds = timeout_seconds
|
||||
self._http_client = http_client
|
||||
|
||||
async def verify_token(self, token: str) -> AccessToken | None:
|
||||
"""Verify WorkOS OAuth token by calling userinfo endpoint."""
|
||||
try:
|
||||
async with (
|
||||
contextlib.nullcontext(self._http_client)
|
||||
if self._http_client is not None
|
||||
else httpx.AsyncClient(timeout=self.timeout_seconds)
|
||||
) as client:
|
||||
# Use WorkOS AuthKit userinfo endpoint to validate token
|
||||
response = await client.get(
|
||||
f"{self.authkit_domain}/oauth2/userinfo",
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"User-Agent": "FastMCP-WorkOS-OAuth",
|
||||
},
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.debug(
|
||||
"WorkOS token verification failed: %d - %s",
|
||||
response.status_code,
|
||||
response.text[:200],
|
||||
)
|
||||
return None
|
||||
|
||||
user_data = response.json()
|
||||
token_scopes = (
|
||||
parse_scopes(user_data.get("scope") or user_data.get("scopes"))
|
||||
or []
|
||||
)
|
||||
|
||||
if self.required_scopes and not all(
|
||||
scope in token_scopes for scope in self.required_scopes
|
||||
):
|
||||
logger.debug(
|
||||
"WorkOS token missing required scopes. required=%s actual=%s",
|
||||
self.required_scopes,
|
||||
token_scopes,
|
||||
)
|
||||
return None
|
||||
|
||||
# Create AccessToken with WorkOS user info
|
||||
return AccessToken(
|
||||
token=token,
|
||||
client_id=str(user_data.get("sub", "unknown")),
|
||||
scopes=token_scopes,
|
||||
expires_at=None, # Will be set from token introspection if needed
|
||||
claims={
|
||||
"sub": user_data.get("sub"),
|
||||
"email": user_data.get("email"),
|
||||
"email_verified": user_data.get("email_verified"),
|
||||
"name": user_data.get("name"),
|
||||
"given_name": user_data.get("given_name"),
|
||||
"family_name": user_data.get("family_name"),
|
||||
},
|
||||
)
|
||||
|
||||
except httpx.RequestError as e:
|
||||
logger.debug("Failed to verify WorkOS token: %s", e)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.debug("WorkOS token verification error: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
class WorkOSProvider(OAuthProxy):
|
||||
"""Complete WorkOS OAuth provider for FastMCP.
|
||||
|
||||
This provider implements WorkOS AuthKit OAuth using the OAuth Proxy pattern.
|
||||
It provides OAuth2 authentication for users through WorkOS Connect applications.
|
||||
|
||||
Features:
|
||||
- Transparent OAuth proxy to WorkOS AuthKit
|
||||
- Automatic token validation via userinfo endpoint
|
||||
- User information extraction from ID tokens
|
||||
- Support for standard OAuth scopes (openid, profile, email)
|
||||
|
||||
Setup Requirements:
|
||||
1. Create a WorkOS Connect application in your dashboard
|
||||
2. Note your AuthKit domain (e.g., "https://your-app.authkit.app")
|
||||
3. Configure redirect URI as: http://localhost:8000/auth/callback
|
||||
4. Note your Client ID and Client Secret
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.workos import WorkOSProvider
|
||||
|
||||
auth = WorkOSProvider(
|
||||
client_id="client_123",
|
||||
client_secret="sk_test_456",
|
||||
authkit_domain="https://your-app.authkit.app",
|
||||
base_url="http://localhost:8000"
|
||||
)
|
||||
|
||||
mcp = FastMCP("My App", auth=auth)
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
client_id: str,
|
||||
client_secret: str,
|
||||
authkit_domain: str,
|
||||
base_url: AnyHttpUrl | str,
|
||||
resource_base_url: AnyHttpUrl | str | None = None,
|
||||
issuer_url: AnyHttpUrl | str | None = None,
|
||||
redirect_path: str | None = None,
|
||||
required_scopes: list[str] | None = None,
|
||||
timeout_seconds: int = 10,
|
||||
allowed_client_redirect_uris: list[str] | None = None,
|
||||
client_storage: AsyncKeyValue | None = None,
|
||||
jwt_signing_key: str | bytes | None = None,
|
||||
require_authorization_consent: bool | Literal["remember", "external"] = True,
|
||||
consent_csp_policy: str | None = None,
|
||||
forward_resource: bool = True,
|
||||
http_client: httpx.AsyncClient | None = None,
|
||||
enable_cimd: bool = True,
|
||||
):
|
||||
"""Initialize WorkOS OAuth provider.
|
||||
|
||||
Args:
|
||||
client_id: WorkOS client ID
|
||||
client_secret: WorkOS client secret
|
||||
authkit_domain: Your WorkOS AuthKit domain (e.g., "https://your-app.authkit.app")
|
||||
base_url: Public URL where OAuth endpoints will be accessible (includes any mount path)
|
||||
resource_base_url: Optional public base URL for the protected resource metadata
|
||||
and token audience. Defaults to ``base_url``.
|
||||
issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL
|
||||
to avoid 404s during discovery when mounting under a path.
|
||||
redirect_path: Redirect path configured in WorkOS (defaults to "/auth/callback")
|
||||
required_scopes: Required OAuth scopes (no default)
|
||||
timeout_seconds: HTTP request timeout for WorkOS API calls (defaults to 10)
|
||||
allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients.
|
||||
If None (default), all URIs are allowed. If empty list, no URIs are allowed.
|
||||
client_storage: Storage backend for OAuth state (client registrations, encrypted tokens).
|
||||
If None, an encrypted file store will be created in the data directory
|
||||
(derived from `platformdirs`).
|
||||
jwt_signing_key: Secret for signing FastMCP JWT tokens (any string or bytes). If bytes are provided,
|
||||
they will be used as is. If a string is provided, it will be derived into a 32-byte key. If not
|
||||
provided, the upstream client secret will be used to derive a 32-byte key using PBKDF2.
|
||||
require_authorization_consent: Whether to require user consent before authorizing clients (default True).
|
||||
When True, users see a consent screen before being redirected to WorkOS.
|
||||
When False, authorization proceeds directly without user confirmation.
|
||||
When "external", the built-in consent screen is skipped but no warning is
|
||||
logged, indicating that consent is handled externally (e.g. by the upstream IdP).
|
||||
SECURITY WARNING: Only set to False for local development or testing environments.
|
||||
http_client: Optional httpx.AsyncClient for connection pooling in token verification.
|
||||
When provided, the client is reused across verify_token calls and the caller
|
||||
is responsible for its lifecycle. When None (default), a fresh client is created per call.
|
||||
enable_cimd: Enable CIMD (Client ID Metadata Document) support for URL-based
|
||||
client IDs (default True). Set to False to disable.
|
||||
"""
|
||||
# Apply defaults and ensure authkit_domain is a full URL
|
||||
authkit_domain_str = authkit_domain
|
||||
if not authkit_domain_str.startswith(("http://", "https://")):
|
||||
authkit_domain_str = f"https://{authkit_domain_str}"
|
||||
authkit_domain_final = authkit_domain_str.rstrip("/")
|
||||
scopes_final = (
|
||||
parse_scopes(required_scopes) if required_scopes is not None else []
|
||||
)
|
||||
|
||||
# Create WorkOS token verifier
|
||||
token_verifier = WorkOSTokenVerifier(
|
||||
authkit_domain=authkit_domain_final,
|
||||
required_scopes=scopes_final,
|
||||
timeout_seconds=timeout_seconds,
|
||||
http_client=http_client,
|
||||
)
|
||||
|
||||
# Initialize OAuth proxy with WorkOS AuthKit endpoints
|
||||
super().__init__(
|
||||
upstream_authorization_endpoint=f"{authkit_domain_final}/oauth2/authorize",
|
||||
upstream_token_endpoint=f"{authkit_domain_final}/oauth2/token",
|
||||
upstream_client_id=client_id,
|
||||
upstream_client_secret=client_secret,
|
||||
token_verifier=token_verifier,
|
||||
base_url=base_url,
|
||||
resource_base_url=resource_base_url,
|
||||
redirect_path=redirect_path,
|
||||
issuer_url=issuer_url or base_url, # Default to base_url if not specified
|
||||
allowed_client_redirect_uris=allowed_client_redirect_uris,
|
||||
client_storage=client_storage,
|
||||
jwt_signing_key=jwt_signing_key,
|
||||
require_authorization_consent=require_authorization_consent,
|
||||
consent_csp_policy=consent_csp_policy,
|
||||
forward_resource=forward_resource,
|
||||
enable_cimd=enable_cimd,
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"Initialized WorkOS OAuth provider for client %s with AuthKit domain %s",
|
||||
client_id,
|
||||
authkit_domain_final,
|
||||
)
|
||||
|
||||
|
||||
class AuthKitProvider(RemoteAuthProvider):
|
||||
"""AuthKit metadata provider for DCR (Dynamic Client Registration).
|
||||
|
||||
This provider implements AuthKit integration using metadata forwarding
|
||||
instead of OAuth proxying. This is the recommended approach for WorkOS DCR
|
||||
as it allows WorkOS to handle the OAuth flow directly while FastMCP acts
|
||||
as a resource server.
|
||||
|
||||
IMPORTANT SETUP REQUIREMENTS:
|
||||
|
||||
1. Enable Dynamic Client Registration in WorkOS Dashboard:
|
||||
- Go to Applications → Configuration
|
||||
- Toggle "Dynamic Client Registration" to enabled
|
||||
|
||||
2. Configure your FastMCP server URL as a callback:
|
||||
- Add your server URL to the Redirects tab in WorkOS dashboard
|
||||
- Example: https://your-fastmcp-server.com/oauth2/callback
|
||||
|
||||
For detailed setup instructions, see:
|
||||
https://workos.com/docs/authkit/mcp/integrating/token-verification
|
||||
|
||||
Token audience is bound to this server automatically: when the MCP
|
||||
mount path becomes known (typically at ``http_app()`` construction),
|
||||
``JWTVerifier.audience`` is set to the resource URL advertised in
|
||||
``.well-known/oauth-protected-resource``. Enable Resource Indicators
|
||||
(RFC 8707) in your WorkOS Dashboard and list that same URL — AuthKit
|
||||
will then mint tokens with the matching ``aud`` claim.
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastmcp.server.auth.providers.workos import AuthKitProvider
|
||||
|
||||
workos_auth = AuthKitProvider(
|
||||
authkit_domain="https://your-workos-domain.authkit.app",
|
||||
base_url="https://your-fastmcp-server.com",
|
||||
)
|
||||
|
||||
mcp = FastMCP("My App", auth=workos_auth)
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
authkit_domain: AnyHttpUrl | str,
|
||||
base_url: AnyHttpUrl | str,
|
||||
resource_base_url: AnyHttpUrl | str | None = None,
|
||||
required_scopes: list[str] | None = None,
|
||||
scopes_supported: list[str] | None = None,
|
||||
resource_name: str | None = None,
|
||||
resource_documentation: AnyHttpUrl | None = None,
|
||||
token_verifier: TokenVerifier | None = None,
|
||||
):
|
||||
"""Initialize AuthKit metadata provider.
|
||||
|
||||
Args:
|
||||
authkit_domain: Your AuthKit domain (e.g., "https://your-app.authkit.app")
|
||||
base_url: Public URL of this FastMCP server
|
||||
resource_base_url: Optional public base URL for the protected resource.
|
||||
When provided, this URL is advertised in protected resource metadata
|
||||
instead of ``base_url``. Useful when OAuth callbacks and the protected
|
||||
MCP resource live under different public URLs.
|
||||
required_scopes: Optional list of scopes to require for all requests
|
||||
scopes_supported: Optional list of scopes to advertise in OAuth metadata.
|
||||
If None, uses required_scopes. Use this when the scopes clients should
|
||||
request differ from the scopes enforced on tokens.
|
||||
resource_name: Optional name for the protected resource metadata.
|
||||
resource_documentation: Optional documentation URL for the protected resource.
|
||||
token_verifier: Optional token verifier. If provided, it is used as-is and
|
||||
audience auto-wiring is skipped — the caller is responsible for setting
|
||||
an appropriate ``audience``. If None (default), a ``JWTVerifier`` is
|
||||
created with audience bound to this server's resource URL.
|
||||
"""
|
||||
self.authkit_domain = str(authkit_domain).rstrip("/")
|
||||
self.base_url = AnyHttpUrl(str(base_url).rstrip("/"))
|
||||
|
||||
# Parse scopes if provided as string
|
||||
parsed_scopes = (
|
||||
parse_scopes(required_scopes) if required_scopes is not None else None
|
||||
)
|
||||
|
||||
# When no custom verifier is provided, we own the JWTVerifier and can
|
||||
# bind its audience to our resource URL once set_mcp_path() is called.
|
||||
self._auto_bind_audience = token_verifier is None
|
||||
if token_verifier is None:
|
||||
token_verifier = JWTVerifier(
|
||||
jwks_uri=f"{self.authkit_domain}/oauth2/jwks",
|
||||
issuer=self.authkit_domain,
|
||||
algorithm="RS256",
|
||||
required_scopes=parsed_scopes,
|
||||
)
|
||||
|
||||
# Initialize RemoteAuthProvider with AuthKit as the authorization server
|
||||
super().__init__(
|
||||
token_verifier=token_verifier,
|
||||
authorization_servers=[AnyHttpUrl(self.authkit_domain)],
|
||||
base_url=self.base_url,
|
||||
resource_base_url=resource_base_url,
|
||||
scopes_supported=scopes_supported,
|
||||
resource_name=resource_name,
|
||||
resource_documentation=resource_documentation,
|
||||
)
|
||||
|
||||
def set_mcp_path(self, mcp_path: str | None) -> None:
|
||||
"""Bind the default verifier's audience to this server's resource URL.
|
||||
|
||||
AuthKit with Resource Indicators (RFC 8707) mints tokens whose ``aud``
|
||||
claim equals the resource URL the client requested — which is the URL
|
||||
we advertise in ``.well-known/oauth-protected-resource``. Binding the
|
||||
audience here keeps validation in lock-step with what clients are sent.
|
||||
"""
|
||||
super().set_mcp_path(mcp_path)
|
||||
if (
|
||||
self._auto_bind_audience
|
||||
and self._resource_url is not None
|
||||
and isinstance(self.token_verifier, JWTVerifier)
|
||||
):
|
||||
resource_url = str(self._resource_url)
|
||||
self.token_verifier.audience = resource_url
|
||||
logger.info(
|
||||
"AuthKit tokens will be validated against aud=%s. "
|
||||
"Configure this URL as a Resource Indicator in the WorkOS Dashboard.",
|
||||
resource_url,
|
||||
)
|
||||
|
||||
def get_routes(
|
||||
self,
|
||||
mcp_path: str | None = None,
|
||||
) -> list[Route]:
|
||||
"""Get OAuth routes including AuthKit authorization server metadata forwarding.
|
||||
|
||||
This returns the standard protected resource routes plus an authorization server
|
||||
metadata endpoint that forwards AuthKit's OAuth metadata to clients.
|
||||
|
||||
Args:
|
||||
mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp")
|
||||
This is used to advertise the resource URL in metadata.
|
||||
"""
|
||||
# Get the standard protected resource routes from RemoteAuthProvider
|
||||
routes = super().get_routes(mcp_path)
|
||||
|
||||
async def oauth_authorization_server_metadata(request):
|
||||
"""Forward AuthKit OAuth authorization server metadata with FastMCP customizations."""
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
f"{self.authkit_domain}/.well-known/oauth-authorization-server"
|
||||
)
|
||||
response.raise_for_status()
|
||||
metadata = response.json()
|
||||
return JSONResponse(metadata)
|
||||
except Exception as e:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "server_error",
|
||||
"error_description": f"Failed to fetch AuthKit metadata: {e}",
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
# Add AuthKit authorization server metadata forwarding
|
||||
routes.append(
|
||||
Route(
|
||||
"/.well-known/oauth-authorization-server",
|
||||
endpoint=oauth_authorization_server_metadata,
|
||||
methods=["GET"],
|
||||
)
|
||||
)
|
||||
|
||||
return routes
|
||||
__all__ = ["AuthKitProvider", "WorkOSProvider", "WorkOSTokenVerifier"]
|
||||
|
|
|
|||
|
|
@ -1,67 +1,3 @@
|
|||
"""Auth plugins for FastMCP."""
|
||||
"""Auth plugin namespace for FastMCP."""
|
||||
|
||||
from fastmcp.server.plugins.auth.providers import (
|
||||
Auth0Auth,
|
||||
Auth0AuthConfig,
|
||||
AuthKitAuth,
|
||||
AuthKitAuthConfig,
|
||||
AWSCognitoAuth,
|
||||
AWSCognitoAuthConfig,
|
||||
AzureAuth,
|
||||
AzureAuthConfig,
|
||||
ClerkAuth,
|
||||
ClerkAuthConfig,
|
||||
DescopeAuth,
|
||||
DescopeAuthConfig,
|
||||
DiscordAuth,
|
||||
DiscordAuthConfig,
|
||||
GitHubAuth,
|
||||
GitHubAuthConfig,
|
||||
GoogleAuth,
|
||||
GoogleAuthConfig,
|
||||
KeycloakAuth,
|
||||
KeycloakAuthConfig,
|
||||
OCIAuth,
|
||||
OCIAuthConfig,
|
||||
PropelAuth,
|
||||
PropelAuthConfig,
|
||||
ScalekitAuth,
|
||||
ScalekitAuthConfig,
|
||||
SupabaseAuth,
|
||||
SupabaseAuthConfig,
|
||||
WorkOSAuth,
|
||||
WorkOSAuthConfig,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AWSCognitoAuth",
|
||||
"AWSCognitoAuthConfig",
|
||||
"Auth0Auth",
|
||||
"Auth0AuthConfig",
|
||||
"AuthKitAuth",
|
||||
"AuthKitAuthConfig",
|
||||
"AzureAuth",
|
||||
"AzureAuthConfig",
|
||||
"ClerkAuth",
|
||||
"ClerkAuthConfig",
|
||||
"DescopeAuth",
|
||||
"DescopeAuthConfig",
|
||||
"DiscordAuth",
|
||||
"DiscordAuthConfig",
|
||||
"GitHubAuth",
|
||||
"GitHubAuthConfig",
|
||||
"GoogleAuth",
|
||||
"GoogleAuthConfig",
|
||||
"KeycloakAuth",
|
||||
"KeycloakAuthConfig",
|
||||
"OCIAuth",
|
||||
"OCIAuthConfig",
|
||||
"PropelAuth",
|
||||
"PropelAuthConfig",
|
||||
"ScalekitAuth",
|
||||
"ScalekitAuthConfig",
|
||||
"SupabaseAuth",
|
||||
"SupabaseAuthConfig",
|
||||
"WorkOSAuth",
|
||||
"WorkOSAuthConfig",
|
||||
]
|
||||
__all__: list[str] = []
|
||||
|
|
|
|||
65
src/fastmcp/server/plugins/auth/_base.py
Normal file
65
src/fastmcp/server/plugins/auth/_base.py
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
"""Shared primitives for first-party auth plugins."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Generic, Literal, TypeVar
|
||||
|
||||
from pydantic import AnyHttpUrl, BaseModel, ConfigDict
|
||||
|
||||
from fastmcp.server.plugins.base import Plugin
|
||||
|
||||
ConsentMode = bool | Literal["remember", "external"]
|
||||
Algorithm = Literal["RS256", "ES256"]
|
||||
ConfigT = TypeVar("ConfigT", bound=BaseModel)
|
||||
|
||||
|
||||
class AuthPlugin(Plugin[ConfigT], Generic[ConfigT]):
|
||||
def _require(self, *fields: str) -> None:
|
||||
missing = [field for field in fields if getattr(self.config, field) is None]
|
||||
if missing:
|
||||
names = ", ".join(f"`{field}`" for field in missing)
|
||||
raise ValueError(f"{type(self).__name__} requires {names}.")
|
||||
|
||||
def _require_one(self, *fields: str) -> None:
|
||||
if not any(getattr(self.config, field) is not None for field in fields):
|
||||
names = " or ".join(f"`{field}`" for field in fields)
|
||||
raise ValueError(f"{type(self).__name__} requires {names}.")
|
||||
|
||||
def _kwargs(self, *fields: str) -> dict[str, Any]:
|
||||
return {
|
||||
field: getattr(self.config, field)
|
||||
for field in fields
|
||||
if getattr(self.config, field) is not None
|
||||
}
|
||||
|
||||
|
||||
class PluginConfig(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class OAuthProxyConfig(PluginConfig):
|
||||
base_url: AnyHttpUrl | str | None = None
|
||||
resource_base_url: AnyHttpUrl | str | None = None
|
||||
issuer_url: AnyHttpUrl | str | None = None
|
||||
redirect_path: str | None = None
|
||||
required_scopes: list[str] | None = None
|
||||
allowed_client_redirect_uris: list[str] | None = None
|
||||
jwt_signing_key: str | None = None
|
||||
require_authorization_consent: ConsentMode = True
|
||||
consent_csp_policy: str | None = None
|
||||
forward_resource: bool = True
|
||||
|
||||
|
||||
class OAuthProviderConfig(OAuthProxyConfig):
|
||||
client_id: str | None = None
|
||||
client_secret: str | None = None
|
||||
timeout_seconds: int = 10
|
||||
enable_cimd: bool = True
|
||||
|
||||
|
||||
class RemoteAuthConfig(PluginConfig):
|
||||
base_url: AnyHttpUrl | str | None = None
|
||||
required_scopes: list[str] | None = None
|
||||
scopes_supported: list[str] | None = None
|
||||
resource_name: str | None = None
|
||||
resource_documentation: AnyHttpUrl | None = None
|
||||
5
src/fastmcp/server/plugins/auth/auth0/__init__.py
Normal file
5
src/fastmcp/server/plugins/auth/auth0/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
"""Auth0 auth plugin."""
|
||||
|
||||
from fastmcp.server.plugins.auth.auth0.plugin import Auth0Auth
|
||||
|
||||
__all__ = ["Auth0Auth"]
|
||||
63
src/fastmcp/server/plugins/auth/auth0/plugin.py
Normal file
63
src/fastmcp/server/plugins/auth/auth0/plugin.py
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
"""Auth0 auth plugin."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from key_value.aio.protocols import AsyncKeyValue
|
||||
from pydantic import AnyHttpUrl
|
||||
|
||||
from fastmcp.server.auth import AuthProvider
|
||||
from fastmcp.server.plugins.auth._base import AuthPlugin, OAuthProxyConfig
|
||||
from fastmcp.server.plugins.auth.auth0.provider import Auth0Provider
|
||||
from fastmcp.server.plugins.base import PluginMeta
|
||||
|
||||
|
||||
class Auth0AuthConfig(OAuthProxyConfig):
|
||||
"""Config model for the Auth0 auth plugin."""
|
||||
|
||||
config_url: AnyHttpUrl | str | None = None
|
||||
client_id: str | None = None
|
||||
client_secret: str | None = None
|
||||
audience: str | None = None
|
||||
|
||||
|
||||
class Auth0Auth(AuthPlugin[Auth0AuthConfig]):
|
||||
"""Contribute an `Auth0Provider` as the server's auth provider."""
|
||||
|
||||
Config: ClassVar[type[Auth0AuthConfig]] = Auth0AuthConfig
|
||||
|
||||
meta = PluginMeta(name="auth0-auth")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: Auth0AuthConfig | dict[str, Any] | None = None,
|
||||
*,
|
||||
client_storage: AsyncKeyValue | None = None,
|
||||
) -> None:
|
||||
super().__init__(config)
|
||||
self._client_storage = client_storage
|
||||
|
||||
def auth(self) -> AuthProvider | None:
|
||||
self._require(
|
||||
"config_url", "client_id", "client_secret", "audience", "base_url"
|
||||
)
|
||||
return Auth0Provider(
|
||||
**self._kwargs(
|
||||
"config_url",
|
||||
"client_id",
|
||||
"client_secret",
|
||||
"audience",
|
||||
"base_url",
|
||||
"resource_base_url",
|
||||
"issuer_url",
|
||||
"required_scopes",
|
||||
"redirect_path",
|
||||
"allowed_client_redirect_uris",
|
||||
"jwt_signing_key",
|
||||
"require_authorization_consent",
|
||||
"consent_csp_policy",
|
||||
"forward_resource",
|
||||
),
|
||||
client_storage=self._client_storage,
|
||||
)
|
||||
135
src/fastmcp/server/plugins/auth/auth0/provider.py
Normal file
135
src/fastmcp/server/plugins/auth/auth0/provider.py
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
"""Auth0 OAuth provider for FastMCP.
|
||||
|
||||
This module provides a complete Auth0 integration that's ready to use with
|
||||
just the configuration URL, client ID, client secret, audience, and base URL.
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.plugins.auth.auth0.provider import Auth0Provider
|
||||
|
||||
# Simple Auth0 OAuth protection
|
||||
auth = Auth0Provider(
|
||||
config_url="https://auth0.config.url",
|
||||
client_id="your-auth0-client-id",
|
||||
client_secret="your-auth0-client-secret",
|
||||
audience="your-auth0-api-audience",
|
||||
base_url="http://localhost:8000",
|
||||
)
|
||||
|
||||
mcp = FastMCP("My Protected Server", auth=auth)
|
||||
```
|
||||
"""
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from key_value.aio.protocols import AsyncKeyValue
|
||||
from pydantic import AnyHttpUrl
|
||||
|
||||
from fastmcp.server.auth.oidc_proxy import OIDCProxy
|
||||
from fastmcp.utilities.auth import parse_scopes
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class Auth0Provider(OIDCProxy):
|
||||
"""An Auth0 provider implementation for FastMCP.
|
||||
|
||||
This provider is a complete Auth0 integration that's ready to use with
|
||||
just the configuration URL, client ID, client secret, audience, and base URL.
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.plugins.auth.auth0.provider import Auth0Provider
|
||||
|
||||
# Simple Auth0 OAuth protection
|
||||
auth = Auth0Provider(
|
||||
config_url="https://auth0.config.url",
|
||||
client_id="your-auth0-client-id",
|
||||
client_secret="your-auth0-client-secret",
|
||||
audience="your-auth0-api-audience",
|
||||
base_url="http://localhost:8000",
|
||||
)
|
||||
|
||||
mcp = FastMCP("My Protected Server", auth=auth)
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
config_url: AnyHttpUrl | str,
|
||||
client_id: str,
|
||||
client_secret: str,
|
||||
audience: str,
|
||||
base_url: AnyHttpUrl | str,
|
||||
resource_base_url: AnyHttpUrl | str | None = None,
|
||||
issuer_url: AnyHttpUrl | str | None = None,
|
||||
required_scopes: list[str] | None = None,
|
||||
redirect_path: str | None = None,
|
||||
allowed_client_redirect_uris: list[str] | None = None,
|
||||
client_storage: AsyncKeyValue | None = None,
|
||||
jwt_signing_key: str | bytes | None = None,
|
||||
require_authorization_consent: bool | Literal["remember", "external"] = True,
|
||||
consent_csp_policy: str | None = None,
|
||||
forward_resource: bool = True,
|
||||
) -> None:
|
||||
"""Initialize Auth0 OAuth provider.
|
||||
|
||||
Args:
|
||||
config_url: Auth0 config URL
|
||||
client_id: Auth0 application client id
|
||||
client_secret: Auth0 application client secret
|
||||
audience: Auth0 API audience
|
||||
base_url: Public URL where OAuth endpoints will be accessible (includes any mount path)
|
||||
resource_base_url: Optional public base URL for the protected resource metadata
|
||||
and token audience. Defaults to ``base_url``.
|
||||
issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL
|
||||
to avoid 404s during discovery when mounting under a path.
|
||||
required_scopes: Required Auth0 scopes (defaults to ["openid"])
|
||||
redirect_path: Redirect path configured in Auth0 application
|
||||
allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients.
|
||||
If None (default), all URIs are allowed. If empty list, no URIs are allowed.
|
||||
client_storage: Storage backend for OAuth state (client registrations, encrypted tokens).
|
||||
If None, an encrypted file store will be created in the data directory
|
||||
(derived from `platformdirs`).
|
||||
jwt_signing_key: Secret for signing FastMCP JWT tokens (any string or bytes). If bytes are provided,
|
||||
they will be used as is. If a string is provided, it will be derived into a 32-byte key. If not
|
||||
provided, the upstream client secret will be used to derive a 32-byte key using PBKDF2.
|
||||
require_authorization_consent: Whether to require user consent before authorizing clients (default True).
|
||||
When True, users see a consent screen before being redirected to Auth0.
|
||||
When False, authorization proceeds directly without user confirmation.
|
||||
When "external", the built-in consent screen is skipped but no warning is
|
||||
logged, indicating that consent is handled externally (e.g. by the upstream IdP).
|
||||
SECURITY WARNING: Only set to False for local development or testing environments.
|
||||
"""
|
||||
# Parse scopes if provided as string
|
||||
auth0_required_scopes = (
|
||||
parse_scopes(required_scopes) if required_scopes is not None else ["openid"]
|
||||
)
|
||||
|
||||
super().__init__(
|
||||
config_url=config_url,
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
audience=audience,
|
||||
base_url=base_url,
|
||||
resource_base_url=resource_base_url,
|
||||
issuer_url=issuer_url,
|
||||
redirect_path=redirect_path,
|
||||
required_scopes=auth0_required_scopes,
|
||||
allowed_client_redirect_uris=allowed_client_redirect_uris,
|
||||
client_storage=client_storage,
|
||||
jwt_signing_key=jwt_signing_key,
|
||||
require_authorization_consent=require_authorization_consent,
|
||||
consent_csp_policy=consent_csp_policy,
|
||||
forward_resource=forward_resource,
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"Initialized Auth0 OAuth provider for client %s with scopes: %s",
|
||||
client_id,
|
||||
auth0_required_scopes,
|
||||
)
|
||||
5
src/fastmcp/server/plugins/auth/authkit/__init__.py
Normal file
5
src/fastmcp/server/plugins/auth/authkit/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
"""WorkOS AuthKit auth plugin."""
|
||||
|
||||
from fastmcp.server.plugins.auth.authkit.plugin import AuthKitAuth
|
||||
|
||||
__all__ = ["AuthKitAuth"]
|
||||
51
src/fastmcp/server/plugins/auth/authkit/plugin.py
Normal file
51
src/fastmcp/server/plugins/auth/authkit/plugin.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
"""WorkOS AuthKit auth plugin."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from pydantic import AnyHttpUrl
|
||||
|
||||
from fastmcp.server.auth import AuthProvider, TokenVerifier
|
||||
from fastmcp.server.plugins.auth._base import AuthPlugin, RemoteAuthConfig
|
||||
from fastmcp.server.plugins.auth.authkit.provider import AuthKitProvider
|
||||
from fastmcp.server.plugins.base import PluginMeta
|
||||
|
||||
|
||||
class AuthKitAuthConfig(RemoteAuthConfig):
|
||||
"""Config model for the WorkOS AuthKit auth plugin."""
|
||||
|
||||
authkit_domain: AnyHttpUrl | str | None = None
|
||||
resource_base_url: AnyHttpUrl | str | None = None
|
||||
|
||||
|
||||
class AuthKitAuth(AuthPlugin[AuthKitAuthConfig]):
|
||||
"""Contribute an `AuthKitProvider` as the server's auth provider."""
|
||||
|
||||
Config: ClassVar[type[AuthKitAuthConfig]] = AuthKitAuthConfig
|
||||
|
||||
meta = PluginMeta(name="authkit-auth")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: AuthKitAuthConfig | dict[str, Any] | None = None,
|
||||
*,
|
||||
token_verifier: TokenVerifier | None = None,
|
||||
) -> None:
|
||||
super().__init__(config)
|
||||
self._token_verifier = token_verifier
|
||||
|
||||
def auth(self) -> AuthProvider | None:
|
||||
self._require("authkit_domain", "base_url")
|
||||
return AuthKitProvider(
|
||||
**self._kwargs(
|
||||
"authkit_domain",
|
||||
"base_url",
|
||||
"resource_base_url",
|
||||
"required_scopes",
|
||||
"scopes_supported",
|
||||
"resource_name",
|
||||
"resource_documentation",
|
||||
),
|
||||
token_verifier=self._token_verifier,
|
||||
)
|
||||
186
src/fastmcp/server/plugins/auth/authkit/provider.py
Normal file
186
src/fastmcp/server/plugins/auth/authkit/provider.py
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
"""WorkOS AuthKit provider."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
from pydantic import AnyHttpUrl
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.routing import Route
|
||||
|
||||
from fastmcp.server.auth import RemoteAuthProvider, TokenVerifier
|
||||
from fastmcp.server.auth.providers.jwt import JWTVerifier
|
||||
from fastmcp.utilities.auth import parse_scopes
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class AuthKitProvider(RemoteAuthProvider):
|
||||
"""AuthKit metadata provider for DCR (Dynamic Client Registration).
|
||||
|
||||
This provider implements AuthKit integration using metadata forwarding
|
||||
instead of OAuth proxying. This is the recommended approach for WorkOS DCR
|
||||
as it allows WorkOS to handle the OAuth flow directly while FastMCP acts
|
||||
as a resource server.
|
||||
|
||||
IMPORTANT SETUP REQUIREMENTS:
|
||||
|
||||
1. Enable Dynamic Client Registration in WorkOS Dashboard:
|
||||
- Go to Applications -> Configuration
|
||||
- Toggle "Dynamic Client Registration" to enabled
|
||||
|
||||
2. Configure your FastMCP server URL as a callback:
|
||||
- Add your server URL to the Redirects tab in WorkOS dashboard
|
||||
- Example: https://your-fastmcp-server.com/oauth2/callback
|
||||
|
||||
For detailed setup instructions, see:
|
||||
https://workos.com/docs/authkit/mcp/integrating/token-verification
|
||||
|
||||
Token audience is bound to this server automatically: when the MCP
|
||||
mount path becomes known (typically at ``http_app()`` construction),
|
||||
``JWTVerifier.audience`` is set to the resource URL advertised in
|
||||
``.well-known/oauth-protected-resource``. Enable Resource Indicators
|
||||
(RFC 8707) in your WorkOS Dashboard and list that same URL — AuthKit
|
||||
will then mint tokens with the matching ``aud`` claim.
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastmcp.server.plugins.auth.authkit.provider import AuthKitProvider
|
||||
|
||||
workos_auth = AuthKitProvider(
|
||||
authkit_domain="https://your-workos-domain.authkit.app",
|
||||
base_url="https://your-fastmcp-server.com",
|
||||
)
|
||||
|
||||
mcp = FastMCP("My App", auth=workos_auth)
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
authkit_domain: AnyHttpUrl | str,
|
||||
base_url: AnyHttpUrl | str,
|
||||
resource_base_url: AnyHttpUrl | str | None = None,
|
||||
required_scopes: list[str] | None = None,
|
||||
scopes_supported: list[str] | None = None,
|
||||
resource_name: str | None = None,
|
||||
resource_documentation: AnyHttpUrl | None = None,
|
||||
token_verifier: TokenVerifier | None = None,
|
||||
):
|
||||
"""Initialize AuthKit metadata provider.
|
||||
|
||||
Args:
|
||||
authkit_domain: Your AuthKit domain (e.g., "https://your-app.authkit.app")
|
||||
base_url: Public URL of this FastMCP server
|
||||
resource_base_url: Optional public base URL for the protected resource.
|
||||
When provided, this URL is advertised in protected resource metadata
|
||||
instead of ``base_url``. Useful when OAuth callbacks and the protected
|
||||
MCP resource live under different public URLs.
|
||||
required_scopes: Optional list of scopes to require for all requests
|
||||
scopes_supported: Optional list of scopes to advertise in OAuth metadata.
|
||||
If None, uses required_scopes. Use this when the scopes clients should
|
||||
request differ from the scopes enforced on tokens.
|
||||
resource_name: Optional name for the protected resource metadata.
|
||||
resource_documentation: Optional documentation URL for the protected resource.
|
||||
token_verifier: Optional token verifier. If provided, it is used as-is and
|
||||
audience auto-wiring is skipped — the caller is responsible for setting
|
||||
an appropriate ``audience``. If None (default), a ``JWTVerifier`` is
|
||||
created with audience bound to this server's resource URL.
|
||||
"""
|
||||
self.authkit_domain = str(authkit_domain).rstrip("/")
|
||||
self.base_url = AnyHttpUrl(str(base_url).rstrip("/"))
|
||||
|
||||
parsed_scopes = (
|
||||
parse_scopes(required_scopes) if required_scopes is not None else None
|
||||
)
|
||||
|
||||
# When no custom verifier is provided, we own the JWTVerifier and can
|
||||
# bind its audience to our resource URL once set_mcp_path() is called.
|
||||
self._auto_bind_audience = token_verifier is None
|
||||
if token_verifier is None:
|
||||
token_verifier = JWTVerifier(
|
||||
jwks_uri=f"{self.authkit_domain}/oauth2/jwks",
|
||||
issuer=self.authkit_domain,
|
||||
algorithm="RS256",
|
||||
required_scopes=parsed_scopes,
|
||||
)
|
||||
|
||||
super().__init__(
|
||||
token_verifier=token_verifier,
|
||||
authorization_servers=[AnyHttpUrl(self.authkit_domain)],
|
||||
base_url=self.base_url,
|
||||
resource_base_url=resource_base_url,
|
||||
scopes_supported=scopes_supported,
|
||||
resource_name=resource_name,
|
||||
resource_documentation=resource_documentation,
|
||||
)
|
||||
|
||||
def set_mcp_path(self, mcp_path: str | None) -> None:
|
||||
"""Bind the default verifier's audience to this server's resource URL.
|
||||
|
||||
AuthKit with Resource Indicators (RFC 8707) mints tokens whose ``aud``
|
||||
claim equals the resource URL the client requested — which is the URL
|
||||
we advertise in ``.well-known/oauth-protected-resource``. Binding the
|
||||
audience here keeps validation in lock-step with what clients are sent.
|
||||
"""
|
||||
super().set_mcp_path(mcp_path)
|
||||
if (
|
||||
self._auto_bind_audience
|
||||
and self._resource_url is not None
|
||||
and isinstance(self.token_verifier, JWTVerifier)
|
||||
):
|
||||
resource_url = str(self._resource_url)
|
||||
self.token_verifier.audience = resource_url
|
||||
logger.info(
|
||||
"AuthKit tokens will be validated against aud=%s. "
|
||||
"Configure this URL as a Resource Indicator in the WorkOS Dashboard.",
|
||||
resource_url,
|
||||
)
|
||||
|
||||
def get_routes(
|
||||
self,
|
||||
mcp_path: str | None = None,
|
||||
) -> list[Route]:
|
||||
"""Get OAuth routes including AuthKit authorization server metadata forwarding.
|
||||
|
||||
This returns the standard protected resource routes plus an authorization server
|
||||
metadata endpoint that forwards AuthKit's OAuth metadata to clients.
|
||||
|
||||
Args:
|
||||
mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp")
|
||||
This is used to advertise the resource URL in metadata.
|
||||
"""
|
||||
routes = super().get_routes(mcp_path)
|
||||
|
||||
async def oauth_authorization_server_metadata(request):
|
||||
"""Forward AuthKit OAuth authorization server metadata with FastMCP customizations."""
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
f"{self.authkit_domain}/.well-known/oauth-authorization-server"
|
||||
)
|
||||
response.raise_for_status()
|
||||
metadata = response.json()
|
||||
return JSONResponse(metadata)
|
||||
except Exception as e:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "server_error",
|
||||
"error_description": f"Failed to fetch AuthKit metadata: {e}",
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
routes.append(
|
||||
Route(
|
||||
"/.well-known/oauth-authorization-server",
|
||||
endpoint=oauth_authorization_server_metadata,
|
||||
methods=["GET"],
|
||||
)
|
||||
)
|
||||
|
||||
return routes
|
||||
|
||||
|
||||
__all__ = ["AuthKitProvider"]
|
||||
5
src/fastmcp/server/plugins/auth/aws/__init__.py
Normal file
5
src/fastmcp/server/plugins/auth/aws/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
"""AWS Cognito auth plugin."""
|
||||
|
||||
from fastmcp.server.plugins.auth.aws.plugin import AWSCognitoAuth
|
||||
|
||||
__all__ = ["AWSCognitoAuth"]
|
||||
61
src/fastmcp/server/plugins/auth/aws/plugin.py
Normal file
61
src/fastmcp/server/plugins/auth/aws/plugin.py
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
"""AWS Cognito auth plugin."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from key_value.aio.protocols import AsyncKeyValue
|
||||
|
||||
from fastmcp.server.auth import AuthProvider
|
||||
from fastmcp.server.plugins.auth._base import AuthPlugin, OAuthProxyConfig
|
||||
from fastmcp.server.plugins.auth.aws.provider import AWSCognitoProvider
|
||||
from fastmcp.server.plugins.base import PluginMeta
|
||||
|
||||
|
||||
class AWSCognitoAuthConfig(OAuthProxyConfig):
|
||||
"""Config model for the AWS Cognito auth plugin."""
|
||||
|
||||
user_pool_id: str | None = None
|
||||
client_id: str | None = None
|
||||
client_secret: str | None = None
|
||||
aws_region: str = "eu-central-1"
|
||||
redirect_path: str | None = "/auth/callback"
|
||||
|
||||
|
||||
class AWSCognitoAuth(AuthPlugin[AWSCognitoAuthConfig]):
|
||||
"""Contribute an `AWSCognitoProvider` as the server's auth provider."""
|
||||
|
||||
Config: ClassVar[type[AWSCognitoAuthConfig]] = AWSCognitoAuthConfig
|
||||
|
||||
meta = PluginMeta(name="aws-cognito-auth")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: AWSCognitoAuthConfig | dict[str, Any] | None = None,
|
||||
*,
|
||||
client_storage: AsyncKeyValue | None = None,
|
||||
) -> None:
|
||||
super().__init__(config)
|
||||
self._client_storage = client_storage
|
||||
|
||||
def auth(self) -> AuthProvider | None:
|
||||
self._require("user_pool_id", "client_id", "client_secret", "base_url")
|
||||
return AWSCognitoProvider(
|
||||
**self._kwargs(
|
||||
"user_pool_id",
|
||||
"client_id",
|
||||
"client_secret",
|
||||
"base_url",
|
||||
"resource_base_url",
|
||||
"aws_region",
|
||||
"issuer_url",
|
||||
"redirect_path",
|
||||
"required_scopes",
|
||||
"allowed_client_redirect_uris",
|
||||
"jwt_signing_key",
|
||||
"require_authorization_consent",
|
||||
"consent_csp_policy",
|
||||
"forward_resource",
|
||||
),
|
||||
client_storage=self._client_storage,
|
||||
)
|
||||
229
src/fastmcp/server/plugins/auth/aws/provider.py
Normal file
229
src/fastmcp/server/plugins/auth/aws/provider.py
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
"""AWS Cognito OAuth provider for FastMCP.
|
||||
|
||||
This module provides a complete AWS Cognito OAuth integration that's ready to use
|
||||
with a user pool ID, domain prefix, client ID and client secret. It handles all
|
||||
the complexity of AWS Cognito's OAuth flow, token validation, and user management.
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.plugins.auth.aws.provider import AWSCognitoProvider
|
||||
|
||||
# Simple AWS Cognito OAuth protection
|
||||
auth = AWSCognitoProvider(
|
||||
user_pool_id="your-user-pool-id",
|
||||
aws_region="eu-central-1",
|
||||
client_id="your-cognito-client-id",
|
||||
client_secret="your-cognito-client-secret"
|
||||
)
|
||||
|
||||
mcp = FastMCP("My Protected Server", auth=auth)
|
||||
```
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from key_value.aio.protocols import AsyncKeyValue
|
||||
from pydantic import AnyHttpUrl
|
||||
|
||||
from fastmcp.server.auth.auth import AccessToken
|
||||
from fastmcp.server.auth.oidc_proxy import OIDCProxy
|
||||
from fastmcp.server.auth.providers.jwt import JWTVerifier
|
||||
from fastmcp.utilities.auth import parse_scopes
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class AWSCognitoTokenVerifier(JWTVerifier):
|
||||
"""Token verifier for Cognito access tokens.
|
||||
|
||||
Cognito access tokens use a ``client_id`` claim instead of the
|
||||
standard ``aud`` claim. This subclass passes ``audience=None``
|
||||
to the parent (skipping the ``aud`` check) and validates the
|
||||
``client_id`` claim directly.
|
||||
"""
|
||||
|
||||
def __init__(self, *, audience: str | list[str] | None = None, **kwargs):
|
||||
self._expected_client_id = audience
|
||||
super().__init__(audience=None, **kwargs)
|
||||
|
||||
async def verify_token(self, token: str) -> AccessToken | None:
|
||||
"""Verify token and filter claims to Cognito-specific subset."""
|
||||
access_token = await super().verify_token(token)
|
||||
if not access_token:
|
||||
return None
|
||||
|
||||
# Validate client_id claim (Cognito's equivalent of aud)
|
||||
if self._expected_client_id:
|
||||
token_client_id = access_token.claims.get("client_id")
|
||||
if isinstance(self._expected_client_id, list):
|
||||
valid = token_client_id in self._expected_client_id
|
||||
else:
|
||||
valid = token_client_id == self._expected_client_id
|
||||
if not valid:
|
||||
self.logger.debug(
|
||||
"Token validation failed: client_id mismatch (expected %s, got %s)",
|
||||
self._expected_client_id,
|
||||
token_client_id,
|
||||
)
|
||||
return None
|
||||
|
||||
# Filter claims to Cognito-specific subset
|
||||
cognito_claims = {
|
||||
"sub": access_token.claims.get("sub"),
|
||||
"username": access_token.claims.get("username"),
|
||||
"cognito:groups": access_token.claims.get("cognito:groups", []),
|
||||
}
|
||||
|
||||
return AccessToken(
|
||||
token=access_token.token,
|
||||
client_id=access_token.client_id,
|
||||
scopes=access_token.scopes,
|
||||
expires_at=access_token.expires_at,
|
||||
claims=cognito_claims,
|
||||
)
|
||||
|
||||
|
||||
class AWSCognitoProvider(OIDCProxy):
|
||||
"""Complete AWS Cognito OAuth provider for FastMCP.
|
||||
|
||||
This provider makes it trivial to add AWS Cognito OAuth protection to any
|
||||
FastMCP server using OIDC Discovery. Just provide your Cognito User Pool details,
|
||||
client credentials, and a base URL, and you're ready to go.
|
||||
|
||||
Features:
|
||||
- Automatic OIDC Discovery from AWS Cognito User Pool
|
||||
- Automatic JWT token validation via Cognito's public keys
|
||||
- Cognito-specific claim filtering (sub, username, cognito:groups)
|
||||
- Support for Cognito User Pools
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.plugins.auth.aws.provider import AWSCognitoProvider
|
||||
|
||||
auth = AWSCognitoProvider(
|
||||
user_pool_id="eu-central-1_XXXXXXXXX",
|
||||
aws_region="eu-central-1",
|
||||
client_id="your-cognito-client-id",
|
||||
client_secret="your-cognito-client-secret",
|
||||
base_url="https://my-server.com",
|
||||
redirect_path="/custom/callback",
|
||||
)
|
||||
|
||||
mcp = FastMCP("My App", auth=auth)
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
user_pool_id: str,
|
||||
client_id: str,
|
||||
client_secret: str,
|
||||
base_url: AnyHttpUrl | str,
|
||||
resource_base_url: AnyHttpUrl | str | None = None,
|
||||
aws_region: str = "eu-central-1",
|
||||
issuer_url: AnyHttpUrl | str | None = None,
|
||||
redirect_path: str = "/auth/callback",
|
||||
required_scopes: list[str] | None = None,
|
||||
allowed_client_redirect_uris: list[str] | None = None,
|
||||
client_storage: AsyncKeyValue | None = None,
|
||||
jwt_signing_key: str | bytes | None = None,
|
||||
require_authorization_consent: bool | Literal["remember", "external"] = True,
|
||||
consent_csp_policy: str | None = None,
|
||||
forward_resource: bool = True,
|
||||
):
|
||||
"""Initialize AWS Cognito OAuth provider.
|
||||
|
||||
Args:
|
||||
user_pool_id: Your Cognito User Pool ID (e.g., "eu-central-1_XXXXXXXXX")
|
||||
client_id: Cognito app client ID
|
||||
client_secret: Cognito app client secret
|
||||
base_url: Public URL where OAuth endpoints will be accessible (includes any mount path)
|
||||
resource_base_url: Optional public base URL for the protected resource metadata
|
||||
and token audience. Defaults to ``base_url``.
|
||||
aws_region: AWS region where your User Pool is located (defaults to "eu-central-1")
|
||||
issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL
|
||||
to avoid 404s during discovery when mounting under a path.
|
||||
redirect_path: Redirect path configured in Cognito app (defaults to "/auth/callback")
|
||||
required_scopes: Required Cognito scopes (defaults to ["openid"])
|
||||
allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients.
|
||||
If None (default), all URIs are allowed. If empty list, no URIs are allowed.
|
||||
client_storage: Storage backend for OAuth state (client registrations, encrypted tokens).
|
||||
If None, an encrypted file store will be created in the data directory
|
||||
(derived from `platformdirs`).
|
||||
jwt_signing_key: Secret for signing FastMCP JWT tokens (any string or bytes). If bytes are provided,
|
||||
they will be used as is. If a string is provided, it will be derived into a 32-byte key. If not
|
||||
provided, the upstream client secret will be used to derive a 32-byte key using PBKDF2.
|
||||
require_authorization_consent: Whether to require user consent before authorizing clients (default True).
|
||||
When True, users see a consent screen before being redirected to AWS Cognito.
|
||||
When False, authorization proceeds directly without user confirmation.
|
||||
When "external", the built-in consent screen is skipped but no warning is
|
||||
logged, indicating that consent is handled externally (e.g. by the upstream IdP).
|
||||
SECURITY WARNING: Only set to False for local development or testing environments.
|
||||
"""
|
||||
# Parse scopes if provided as string
|
||||
required_scopes_final = (
|
||||
parse_scopes(required_scopes) if required_scopes is not None else ["openid"]
|
||||
)
|
||||
|
||||
# Construct OIDC discovery URL
|
||||
config_url = f"https://cognito-idp.{aws_region}.amazonaws.com/{user_pool_id}/.well-known/openid-configuration"
|
||||
|
||||
# Store Cognito-specific info for claim filtering
|
||||
self.user_pool_id = user_pool_id
|
||||
self.aws_region = aws_region
|
||||
self.client_id = client_id
|
||||
|
||||
# Initialize OIDC proxy with Cognito discovery
|
||||
super().__init__(
|
||||
config_url=config_url,
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
algorithm="RS256",
|
||||
required_scopes=required_scopes_final,
|
||||
base_url=base_url,
|
||||
resource_base_url=resource_base_url,
|
||||
issuer_url=issuer_url,
|
||||
redirect_path=redirect_path,
|
||||
allowed_client_redirect_uris=allowed_client_redirect_uris,
|
||||
client_storage=client_storage,
|
||||
jwt_signing_key=jwt_signing_key,
|
||||
require_authorization_consent=require_authorization_consent,
|
||||
consent_csp_policy=consent_csp_policy,
|
||||
forward_resource=forward_resource,
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"Initialized AWS Cognito OAuth provider for client %s with scopes: %s",
|
||||
client_id,
|
||||
required_scopes_final,
|
||||
)
|
||||
|
||||
def get_token_verifier(
|
||||
self,
|
||||
*,
|
||||
algorithm: str | None = None,
|
||||
audience: str | None = None,
|
||||
required_scopes: list[str] | None = None,
|
||||
timeout_seconds: int | None = None,
|
||||
) -> AWSCognitoTokenVerifier:
|
||||
"""Creates a Cognito-specific token verifier with claim filtering.
|
||||
|
||||
Args:
|
||||
algorithm: Optional token verifier algorithm
|
||||
audience: Optional token verifier audience
|
||||
required_scopes: Optional token verifier required_scopes
|
||||
timeout_seconds: HTTP request timeout in seconds
|
||||
"""
|
||||
return AWSCognitoTokenVerifier(
|
||||
issuer=str(self.oidc_config.issuer),
|
||||
audience=audience or self.client_id,
|
||||
algorithm=algorithm,
|
||||
jwks_uri=str(self.oidc_config.jwks_uri),
|
||||
required_scopes=required_scopes,
|
||||
)
|
||||
5
src/fastmcp/server/plugins/auth/azure/__init__.py
Normal file
5
src/fastmcp/server/plugins/auth/azure/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
"""Azure auth plugin."""
|
||||
|
||||
from fastmcp.server.plugins.auth.azure.plugin import AzureAuth
|
||||
|
||||
__all__ = ["AzureAuth"]
|
||||
69
src/fastmcp/server/plugins/auth/azure/plugin.py
Normal file
69
src/fastmcp/server/plugins/auth/azure/plugin.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
"""Azure auth plugin."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
import httpx
|
||||
from key_value.aio.protocols import AsyncKeyValue
|
||||
|
||||
from fastmcp.server.auth import AuthProvider
|
||||
from fastmcp.server.plugins.auth._base import AuthPlugin, OAuthProviderConfig
|
||||
from fastmcp.server.plugins.auth.azure.provider import AzureProvider
|
||||
from fastmcp.server.plugins.base import PluginMeta
|
||||
|
||||
|
||||
class AzureAuthConfig(OAuthProviderConfig):
|
||||
"""Config model for the Azure auth plugin."""
|
||||
|
||||
tenant_id: str | None = None
|
||||
required_scopes: list[str] | None = None
|
||||
identifier_uri: str | None = None
|
||||
additional_authorize_scopes: list[str] | None = None
|
||||
base_authority: str = "login.microsoftonline.com"
|
||||
|
||||
|
||||
class AzureAuth(AuthPlugin[AzureAuthConfig]):
|
||||
"""Contribute an `AzureProvider` as the server's auth provider."""
|
||||
|
||||
Config: ClassVar[type[AzureAuthConfig]] = AzureAuthConfig
|
||||
|
||||
meta = PluginMeta(name="azure-auth")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: AzureAuthConfig | dict[str, Any] | None = None,
|
||||
*,
|
||||
client_storage: AsyncKeyValue | None = None,
|
||||
http_client: httpx.AsyncClient | None = None,
|
||||
) -> None:
|
||||
super().__init__(config)
|
||||
self._client_storage = client_storage
|
||||
self._http_client = http_client
|
||||
|
||||
def auth(self) -> AuthProvider | None:
|
||||
self._require("client_id", "tenant_id", "required_scopes", "base_url")
|
||||
self._require_one("client_secret", "jwt_signing_key")
|
||||
return AzureProvider(
|
||||
**self._kwargs(
|
||||
"client_id",
|
||||
"client_secret",
|
||||
"tenant_id",
|
||||
"required_scopes",
|
||||
"base_url",
|
||||
"resource_base_url",
|
||||
"identifier_uri",
|
||||
"issuer_url",
|
||||
"redirect_path",
|
||||
"additional_authorize_scopes",
|
||||
"allowed_client_redirect_uris",
|
||||
"jwt_signing_key",
|
||||
"require_authorization_consent",
|
||||
"consent_csp_policy",
|
||||
"forward_resource",
|
||||
"base_authority",
|
||||
"enable_cimd",
|
||||
),
|
||||
client_storage=self._client_storage,
|
||||
http_client=self._http_client,
|
||||
)
|
||||
768
src/fastmcp/server/plugins/auth/azure/provider.py
Normal file
768
src/fastmcp/server/plugins/auth/azure/provider.py
Normal file
|
|
@ -0,0 +1,768 @@
|
|||
"""Azure (Microsoft Entra) OAuth provider for FastMCP.
|
||||
|
||||
This provider implements Azure/Microsoft Entra ID OAuth authentication
|
||||
using the OAuth Proxy pattern for non-DCR OAuth flows.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from collections import OrderedDict
|
||||
from typing import TYPE_CHECKING, Any, Literal, cast
|
||||
|
||||
import httpx
|
||||
from key_value.aio.protocols import AsyncKeyValue
|
||||
|
||||
from fastmcp.dependencies import Dependency
|
||||
from fastmcp.server.auth.auth import MultiAuth
|
||||
from fastmcp.server.auth.oauth_proxy import OAuthProxy
|
||||
from fastmcp.server.auth.providers.jwt import JWTVerifier
|
||||
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
|
||||
from pydantic import AnyHttpUrl
|
||||
|
||||
from fastmcp.server.auth.auth import AuthProvider
|
||||
|
||||
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 AzureProvider(OAuthProxy):
|
||||
"""Azure (Microsoft Entra) OAuth provider for FastMCP.
|
||||
|
||||
This provider implements Azure/Microsoft Entra ID authentication using the
|
||||
OAuth Proxy pattern. It supports both organizational accounts and personal
|
||||
Microsoft accounts depending on the tenant configuration.
|
||||
|
||||
Scope Handling:
|
||||
- required_scopes: Provide unprefixed scope names (e.g., ["read", "write"])
|
||||
→ Automatically prefixed with identifier_uri during initialization
|
||||
→ Validated on all tokens and advertised to MCP clients
|
||||
- additional_authorize_scopes: Provide full format (e.g., ["User.Read"])
|
||||
→ NOT prefixed, NOT validated, NOT advertised to clients
|
||||
→ Used to request Microsoft Graph or other upstream API permissions
|
||||
|
||||
Features:
|
||||
- OAuth proxy to Azure/Microsoft identity platform
|
||||
- JWT validation using tenant issuer and JWKS
|
||||
- Supports tenant configurations: specific tenant ID, "organizations", or "consumers"
|
||||
- Custom API scopes and Microsoft Graph scopes in a single provider
|
||||
|
||||
Setup:
|
||||
1. Create an App registration in Azure Portal
|
||||
2. Configure Web platform redirect URI: http://localhost:8000/auth/callback (or your custom path)
|
||||
3. Add an Application ID URI under "Expose an API" (defaults to api://{client_id})
|
||||
4. Add custom scopes (e.g., "read", "write") under "Expose an API"
|
||||
5. Set access token version to 2 in the App manifest: "requestedAccessTokenVersion": 2
|
||||
6. Create a client secret
|
||||
7. Get Application (client) ID, Directory (tenant) ID, and client secret
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.plugins.auth.azure.provider import AzureProvider
|
||||
|
||||
# Standard Azure (Public Cloud)
|
||||
auth = AzureProvider(
|
||||
client_id="your-client-id",
|
||||
client_secret="your-client-secret",
|
||||
tenant_id="your-tenant-id",
|
||||
required_scopes=["read", "write"], # Unprefixed scope names
|
||||
additional_authorize_scopes=["User.Read", "Mail.Read"], # Optional Graph scopes
|
||||
base_url="http://localhost:8000",
|
||||
# identifier_uri defaults to api://{client_id}
|
||||
)
|
||||
|
||||
# Azure Government
|
||||
auth_gov = AzureProvider(
|
||||
client_id="your-client-id",
|
||||
client_secret="your-client-secret",
|
||||
tenant_id="your-tenant-id",
|
||||
required_scopes=["read", "write"],
|
||||
base_authority="login.microsoftonline.us", # Override for Azure Gov
|
||||
base_url="http://localhost:8000",
|
||||
)
|
||||
|
||||
mcp = FastMCP("My App", auth=auth)
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
client_id: str,
|
||||
client_secret: str | None = None,
|
||||
tenant_id: str,
|
||||
required_scopes: list[str],
|
||||
base_url: str,
|
||||
resource_base_url: AnyHttpUrl | str | None = None,
|
||||
identifier_uri: str | None = None,
|
||||
issuer_url: str | None = None,
|
||||
redirect_path: str | None = None,
|
||||
additional_authorize_scopes: list[str] | None = None,
|
||||
allowed_client_redirect_uris: list[str] | None = None,
|
||||
client_storage: AsyncKeyValue | None = None,
|
||||
jwt_signing_key: str | bytes | None = None,
|
||||
require_authorization_consent: bool | Literal["remember", "external"] = True,
|
||||
consent_csp_policy: str | None = None,
|
||||
forward_resource: bool = True,
|
||||
base_authority: str = "login.microsoftonline.com",
|
||||
http_client: httpx.AsyncClient | None = None,
|
||||
enable_cimd: bool = True,
|
||||
) -> None:
|
||||
"""Initialize Azure OAuth provider.
|
||||
|
||||
Args:
|
||||
client_id: Azure application (client) ID from your App registration
|
||||
client_secret: Azure client secret from your App registration. Optional when
|
||||
using alternative credentials (e.g., managed identity with a custom
|
||||
_create_upstream_oauth_client override). When omitted, jwt_signing_key
|
||||
must be provided.
|
||||
tenant_id: Azure tenant ID (specific tenant GUID, "organizations", or "consumers")
|
||||
identifier_uri: Optional Application ID URI for your custom API (defaults to api://{client_id}).
|
||||
This URI is automatically prefixed to all required_scopes during initialization.
|
||||
Example: identifier_uri="api://my-api" + required_scopes=["read"]
|
||||
→ tokens validated for "api://my-api/read"
|
||||
base_url: Public URL where OAuth endpoints will be accessible (includes any mount path)
|
||||
resource_base_url: Optional public base URL for the protected resource metadata
|
||||
and token audience. Defaults to ``base_url``.
|
||||
issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL
|
||||
to avoid 404s during discovery when mounting under a path.
|
||||
redirect_path: Redirect path configured in Azure App registration (defaults to "/auth/callback")
|
||||
base_authority: Azure authority base URL (defaults to "login.microsoftonline.com").
|
||||
For Azure Government, use "login.microsoftonline.us".
|
||||
required_scopes: Custom API scope names WITHOUT prefix (e.g., ["read", "write"]).
|
||||
- Automatically prefixed with identifier_uri during initialization
|
||||
- Validated on all tokens
|
||||
- Advertised in Protected Resource Metadata
|
||||
- Must match scope names defined in Azure Portal under "Expose an API"
|
||||
Example: ["read", "write"] → validates tokens containing ["api://xxx/read", "api://xxx/write"]
|
||||
additional_authorize_scopes: Microsoft Graph or other upstream scopes in full format.
|
||||
- NOT prefixed with identifier_uri
|
||||
- NOT validated on tokens
|
||||
- NOT advertised to MCP clients
|
||||
- Used to request additional permissions from Azure (e.g., Graph API access)
|
||||
Example: ["User.Read", "Mail.Read"]
|
||||
These scopes allow your FastMCP server to call Microsoft Graph APIs using the
|
||||
upstream Azure token, but MCP clients are unaware of them.
|
||||
Note: "offline_access" is automatically included to obtain refresh tokens.
|
||||
allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients.
|
||||
If None (default), all URIs are allowed. If empty list, no URIs are allowed.
|
||||
client_storage: Storage backend for OAuth state (client registrations, encrypted tokens).
|
||||
If None, an encrypted file store will be created in the data directory
|
||||
(derived from `platformdirs`).
|
||||
jwt_signing_key: Secret for signing FastMCP JWT tokens (any string or bytes). If bytes are provided,
|
||||
they will be used as is. If a string is provided, it will be derived into a 32-byte key. If not
|
||||
provided, the upstream client secret will be used to derive a 32-byte key using PBKDF2.
|
||||
require_authorization_consent: Whether to require user consent before authorizing clients (default True).
|
||||
When True, users see a consent screen before being redirected to Azure.
|
||||
When False, authorization proceeds directly without user confirmation.
|
||||
When "external", the built-in consent screen is skipped but no warning is
|
||||
logged, indicating that consent is handled externally (e.g. by the upstream IdP).
|
||||
SECURITY WARNING: Only set to False for local development or testing environments.
|
||||
http_client: Optional httpx.AsyncClient for connection pooling in JWKS fetches.
|
||||
When provided, the client is reused for JWT key fetches and the caller
|
||||
is responsible for its lifecycle. When None (default), a fresh client is created per fetch.
|
||||
enable_cimd: Enable CIMD (Client ID Metadata Document) support for URL-based
|
||||
client IDs (default True). Set to False to disable.
|
||||
"""
|
||||
# Parse scopes if provided as string
|
||||
parsed_required_scopes = parse_scopes(required_scopes)
|
||||
parsed_additional_scopes: list[str] = (
|
||||
parse_scopes(additional_authorize_scopes) or []
|
||||
if additional_authorize_scopes
|
||||
else []
|
||||
)
|
||||
|
||||
# Always include offline_access to get refresh tokens from Azure
|
||||
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
|
||||
|
||||
# Cache of OBO credentials keyed by hash of user assertion token.
|
||||
# Reusing credentials allows the Azure SDK's internal token cache
|
||||
# to avoid redundant OBO exchanges for the same user + scopes.
|
||||
self._obo_credentials: OrderedDict[str, OnBehalfOfCredential] = OrderedDict()
|
||||
self._obo_max_credentials: int = 128
|
||||
|
||||
# Apply defaults
|
||||
self.identifier_uri = identifier_uri or f"api://{client_id}"
|
||||
self.additional_authorize_scopes: list[str] = parsed_additional_scopes
|
||||
|
||||
# Always validate tokens against the app's API client ID using JWT
|
||||
issuer = f"https://{base_authority}/{tenant_id}/v2.0"
|
||||
jwks_uri = f"https://{base_authority}/{tenant_id}/discovery/v2.0/keys"
|
||||
|
||||
# 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 = [
|
||||
s for s in (parsed_required_scopes or []) if s not in OIDC_SCOPES
|
||||
]
|
||||
if not validation_scopes:
|
||||
raise ValueError(
|
||||
"AzureProvider requires at least one non-OIDC scope in "
|
||||
"required_scopes (e.g., 'read', 'write'). OIDC scopes like "
|
||||
"'openid', 'profile', 'email', and 'offline_access' are not "
|
||||
"included in Azure access token claims and cannot be used for "
|
||||
"scope enforcement."
|
||||
)
|
||||
|
||||
token_verifier = JWTVerifier(
|
||||
jwks_uri=jwks_uri,
|
||||
issuer=issuer,
|
||||
audience=[client_id, self.identifier_uri],
|
||||
algorithm="RS256",
|
||||
required_scopes=validation_scopes, # Only validate non-OIDC scopes
|
||||
http_client=http_client,
|
||||
)
|
||||
|
||||
# Build Azure OAuth endpoints with tenant
|
||||
authorization_endpoint = (
|
||||
f"https://{base_authority}/{tenant_id}/oauth2/v2.0/authorize"
|
||||
)
|
||||
token_endpoint = f"https://{base_authority}/{tenant_id}/oauth2/v2.0/token"
|
||||
|
||||
# Initialize OAuth proxy with Azure endpoints
|
||||
# Remember there's hooks called, such as _prepare_scopes_for_token_exchange
|
||||
# and _prepare_scopes_for_upstream_refresh
|
||||
super().__init__(
|
||||
upstream_authorization_endpoint=authorization_endpoint,
|
||||
upstream_token_endpoint=token_endpoint,
|
||||
upstream_client_id=client_id,
|
||||
upstream_client_secret=client_secret,
|
||||
token_verifier=token_verifier,
|
||||
base_url=base_url,
|
||||
resource_base_url=resource_base_url,
|
||||
redirect_path=redirect_path,
|
||||
issuer_url=issuer_url or base_url, # Default to base_url if not specified
|
||||
allowed_client_redirect_uris=allowed_client_redirect_uris,
|
||||
client_storage=client_storage,
|
||||
jwt_signing_key=jwt_signing_key,
|
||||
require_authorization_consent=require_authorization_consent,
|
||||
consent_csp_policy=consent_csp_policy,
|
||||
forward_resource=forward_resource,
|
||||
valid_scopes=parsed_required_scopes,
|
||||
enable_cimd=enable_cimd,
|
||||
)
|
||||
|
||||
authority_info = ""
|
||||
if base_authority != "login.microsoftonline.com":
|
||||
authority_info = f" using authority {base_authority}"
|
||||
logger.info(
|
||||
"Initialized Azure OAuth provider for client %s with tenant %s%s%s",
|
||||
client_id,
|
||||
tenant_id,
|
||||
f" and identifier_uri {self.identifier_uri}" if self.identifier_uri else "",
|
||||
authority_info,
|
||||
)
|
||||
|
||||
async def authorize(
|
||||
self,
|
||||
client: OAuthClientInformationFull,
|
||||
params: AuthorizationParams,
|
||||
) -> str:
|
||||
"""Start OAuth transaction and redirect to Azure AD.
|
||||
|
||||
Override parent's authorize method to filter out the 'resource' parameter
|
||||
which is not supported by Azure AD v2.0 endpoints. The v2.0 endpoints use
|
||||
scopes to determine the resource/audience instead of a separate parameter.
|
||||
|
||||
Args:
|
||||
client: OAuth client information
|
||||
params: Authorization parameters from the client
|
||||
|
||||
Returns:
|
||||
Authorization URL to redirect the user to Azure AD
|
||||
"""
|
||||
# Clear the resource parameter that Azure AD v2.0 doesn't support
|
||||
# This parameter comes from RFC 8707 (OAuth 2.0 Resource Indicators)
|
||||
# but Azure AD v2.0 uses scopes instead to determine the audience
|
||||
params_to_use = params
|
||||
if hasattr(params, "resource"):
|
||||
original_resource = getattr(params, "resource", None)
|
||||
if original_resource is not None:
|
||||
params_to_use = params.model_copy(update={"resource": None})
|
||||
if original_resource:
|
||||
logger.debug(
|
||||
"Filtering out 'resource' parameter '%s' for Azure AD v2.0 (use scopes instead)",
|
||||
original_resource,
|
||||
)
|
||||
# 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 _prefix_scopes_for_azure(self, scopes: list[str]) -> list[str]:
|
||||
"""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
|
||||
|
||||
Returns:
|
||||
List of scopes with identifier_uri prefix applied where needed
|
||||
"""
|
||||
prefixed = []
|
||||
for scope in scopes:
|
||||
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 custom API scope - prefix with identifier_uri
|
||||
prefixed.append(f"{self.identifier_uri}/{scope}")
|
||||
return prefixed
|
||||
|
||||
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 = self._prefix_scopes_for_azure(unprefixed_scopes)
|
||||
|
||||
# Add Microsoft Graph scopes (not validated, not prefixed)
|
||||
if self.additional_authorize_scopes:
|
||||
prefixed_scopes.extend(self.additional_authorize_scopes)
|
||||
|
||||
# Temporarily modify transaction dict for parent's URL building
|
||||
modified_transaction = transaction.copy()
|
||||
modified_transaction["scopes"] = prefixed_scopes
|
||||
|
||||
# Let parent build the URL with prefixed scopes
|
||||
return super()._build_upstream_authorize_url(txn_id, modified_transaction)
|
||||
|
||||
def _prepare_scopes_for_token_exchange(self, scopes: list[str]) -> list[str]:
|
||||
"""Prepare scopes for Azure authorization code exchange.
|
||||
|
||||
Azure requires scopes during token exchange (AADSTS28003 error if missing).
|
||||
Azure only allows ONE resource per token request (AADSTS28000), so we only
|
||||
include scopes for this API plus OIDC scopes.
|
||||
|
||||
Args:
|
||||
scopes: Scopes from the authorization request (unprefixed)
|
||||
|
||||
Returns:
|
||||
List of scopes for Azure token endpoint
|
||||
"""
|
||||
# Prefix scopes for this API
|
||||
prefixed_scopes = self._prefix_scopes_for_azure(scopes or [])
|
||||
|
||||
# Add OIDC scopes only (not other API scopes) to avoid AADSTS28000
|
||||
if self.additional_authorize_scopes:
|
||||
prefixed_scopes.extend(
|
||||
s for s in self.additional_authorize_scopes if s in OIDC_SCOPES
|
||||
)
|
||||
|
||||
deduplicated = list(dict.fromkeys(prefixed_scopes))
|
||||
logger.debug("Token exchange scopes: %s", deduplicated)
|
||||
return deduplicated
|
||||
|
||||
def _prepare_scopes_for_upstream_refresh(self, scopes: list[str]) -> list[str]:
|
||||
"""Prepare scopes for Azure token refresh.
|
||||
|
||||
Azure requires fully-qualified scopes and only allows ONE resource per
|
||||
token request (AADSTS28000). We include scopes for this API plus OIDC scopes.
|
||||
|
||||
Args:
|
||||
scopes: Base scopes from RefreshToken (unprefixed, e.g., ["read"])
|
||||
|
||||
Returns:
|
||||
Deduplicated list of scopes formatted for Azure token endpoint
|
||||
"""
|
||||
logger.debug("Base scopes from storage: %s", scopes)
|
||||
|
||||
# Filter out any additional_authorize_scopes that may have been stored
|
||||
additional_scopes_set = set(self.additional_authorize_scopes or [])
|
||||
base_scopes = [s for s in scopes if s not in additional_scopes_set]
|
||||
|
||||
# Prefix base scopes with identifier_uri for Azure
|
||||
prefixed_scopes = self._prefix_scopes_for_azure(base_scopes)
|
||||
|
||||
# Add OIDC scopes only (not other API scopes) to avoid AADSTS28000
|
||||
if self.additional_authorize_scopes:
|
||||
prefixed_scopes.extend(
|
||||
s for s in self.additional_authorize_scopes if s in OIDC_SCOPES
|
||||
)
|
||||
|
||||
deduplicated_scopes = list(dict.fromkeys(prefixed_scopes))
|
||||
logger.debug("Scopes for Azure token endpoint: %s", deduplicated_scopes)
|
||||
return deduplicated_scopes
|
||||
|
||||
async def _extract_upstream_claims(
|
||||
self, idp_tokens: dict[str, Any]
|
||||
) -> dict[str, Any] | None:
|
||||
"""Extract claims from Azure token response to embed in FastMCP JWT.
|
||||
|
||||
Decodes the Azure access token (which is a JWT) to extract user identity
|
||||
claims. This allows gateways to inspect upstream identity information by
|
||||
decoding the FastMCP JWT without needing server-side storage lookups.
|
||||
|
||||
Azure access tokens contain claims like:
|
||||
- sub: Subject identifier (unique per user per application)
|
||||
- oid: Object ID (unique user identifier across Azure AD)
|
||||
- tid: Tenant ID
|
||||
- azp: Authorized party (client ID that requested the token)
|
||||
- name: Display name
|
||||
- given_name: First name
|
||||
- family_name: Last name
|
||||
- preferred_username: User principal name (email format)
|
||||
- upn: User Principal Name
|
||||
- email: Email address (if available)
|
||||
- roles: Application roles assigned to the user
|
||||
- groups: Group memberships (if configured)
|
||||
|
||||
Args:
|
||||
idp_tokens: Full token response from Azure, containing access_token
|
||||
and potentially id_token.
|
||||
|
||||
Returns:
|
||||
Dict of extracted claims, or None if extraction fails.
|
||||
"""
|
||||
access_token = idp_tokens.get("access_token")
|
||||
if not access_token:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Azure access tokens are JWTs - decode without verification
|
||||
# (already validated by token_verifier during token exchange)
|
||||
payload = decode_jwt_payload(access_token)
|
||||
|
||||
# Extract useful identity claims
|
||||
claims: dict[str, Any] = {}
|
||||
claim_keys = [
|
||||
"sub",
|
||||
"oid",
|
||||
"tid",
|
||||
"azp",
|
||||
"name",
|
||||
"given_name",
|
||||
"family_name",
|
||||
"preferred_username",
|
||||
"upn",
|
||||
"email",
|
||||
"roles",
|
||||
"groups",
|
||||
]
|
||||
for claim in claim_keys:
|
||||
if claim in payload:
|
||||
claims[claim] = payload[claim]
|
||||
|
||||
if claims:
|
||||
logger.debug(
|
||||
"Extracted %d Azure claims for embedding in FastMCP JWT",
|
||||
len(claims),
|
||||
)
|
||||
return claims
|
||||
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.debug("Failed to extract Azure claims: %s", e)
|
||||
return None
|
||||
|
||||
async def get_obo_credential(self, user_assertion: str) -> OnBehalfOfCredential:
|
||||
"""Get a cached or new OnBehalfOfCredential for OBO token exchange.
|
||||
|
||||
Credentials are cached by user assertion so the Azure SDK's internal
|
||||
token cache can avoid redundant OBO exchanges when the same user
|
||||
calls multiple tools with the same scopes.
|
||||
|
||||
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
|
||||
|
||||
key = hashlib.sha256(user_assertion.encode()).hexdigest()
|
||||
|
||||
if key in self._obo_credentials:
|
||||
self._obo_credentials.move_to_end(key)
|
||||
return self._obo_credentials[key]
|
||||
|
||||
obo_kwargs: dict[str, Any] = {
|
||||
"tenant_id": self._tenant_id,
|
||||
"client_id": self._upstream_client_id,
|
||||
"user_assertion": user_assertion,
|
||||
"authority": f"https://{self._base_authority}",
|
||||
}
|
||||
if self._upstream_client_secret is not None:
|
||||
obo_kwargs["client_secret"] = (
|
||||
self._upstream_client_secret.get_secret_value()
|
||||
)
|
||||
else:
|
||||
raise ValueError(
|
||||
"OBO token exchange requires either a client_secret or a subclass "
|
||||
"that overrides get_obo_credential() to provide alternative credentials "
|
||||
"(e.g., client_assertion_func for managed identity)."
|
||||
)
|
||||
credential = OnBehalfOfCredential(**obo_kwargs)
|
||||
self._obo_credentials[key] = credential
|
||||
|
||||
# Evict oldest if over capacity
|
||||
while len(self._obo_credentials) > self._obo_max_credentials:
|
||||
_, evicted = self._obo_credentials.popitem(last=False)
|
||||
await evicted.close()
|
||||
|
||||
return credential
|
||||
|
||||
async def close_obo_credentials(self) -> None:
|
||||
"""Close all cached OBO credentials."""
|
||||
credentials = list(self._obo_credentials.values())
|
||||
self._obo_credentials.clear()
|
||||
for credential in credentials:
|
||||
try:
|
||||
await credential.close()
|
||||
except Exception:
|
||||
logger.debug("Error closing OBO credential", exc_info=True)
|
||||
|
||||
|
||||
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.plugins.auth.azure.provider 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, self._identifier_uri],
|
||||
algorithm="RS256",
|
||||
required_scopes=required_scopes,
|
||||
)
|
||||
|
||||
@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
|
||||
|
||||
|
||||
# --- Dependency injection support ---
|
||||
# These require fastmcp[azure] extra for azure-identity
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def _find_azure_provider(auth: AuthProvider | None) -> AzureProvider | None:
|
||||
"""Extract an AzureProvider from an auth provider, unwrapping MultiAuth if needed."""
|
||||
if isinstance(auth, AzureProvider):
|
||||
return auth
|
||||
|
||||
if isinstance(auth, MultiAuth) and isinstance(auth.server, AzureProvider):
|
||||
return auth.server
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class _EntraOBOToken(Dependency[str]):
|
||||
"""Dependency that performs OBO token exchange for Microsoft Entra.
|
||||
|
||||
Uses azure.identity's OnBehalfOfCredential for async-native OBO,
|
||||
with automatic token caching and refresh. Credentials are cached on
|
||||
the AzureProvider so repeated tool calls reuse existing credentials
|
||||
and benefit from the Azure SDK's internal token cache.
|
||||
"""
|
||||
|
||||
def __init__(self, scopes: list[str]):
|
||||
self.scopes = scopes
|
||||
|
||||
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()
|
||||
azure_provider = _find_azure_provider(server.auth)
|
||||
if azure_provider is None:
|
||||
raise RuntimeError(
|
||||
"EntraOBOToken requires an AzureProvider as the auth provider. "
|
||||
f"Current provider: {type(server.auth).__name__}"
|
||||
)
|
||||
|
||||
credential = await azure_provider.get_obo_credential(
|
||||
user_assertion=access_token.token,
|
||||
)
|
||||
|
||||
result = await credential.get_token(*self.scopes)
|
||||
return result.token
|
||||
|
||||
|
||||
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.plugins.auth.azure.provider 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))
|
||||
5
src/fastmcp/server/plugins/auth/clerk/__init__.py
Normal file
5
src/fastmcp/server/plugins/auth/clerk/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
"""Clerk auth plugin."""
|
||||
|
||||
from fastmcp.server.plugins.auth.clerk.plugin import ClerkAuth
|
||||
|
||||
__all__ = ["ClerkAuth"]
|
||||
67
src/fastmcp/server/plugins/auth/clerk/plugin.py
Normal file
67
src/fastmcp/server/plugins/auth/clerk/plugin.py
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
"""Clerk auth plugin."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
import httpx
|
||||
from key_value.aio.protocols import AsyncKeyValue
|
||||
|
||||
from fastmcp.server.auth import AuthProvider
|
||||
from fastmcp.server.plugins.auth._base import AuthPlugin, OAuthProviderConfig
|
||||
from fastmcp.server.plugins.auth.clerk.provider import ClerkProvider
|
||||
from fastmcp.server.plugins.base import PluginMeta
|
||||
|
||||
|
||||
class ClerkAuthConfig(OAuthProviderConfig):
|
||||
"""Config model for the Clerk auth plugin."""
|
||||
|
||||
domain: str | None = None
|
||||
valid_scopes: list[str] | None = None
|
||||
extra_authorize_params: dict[str, str] | None = None
|
||||
|
||||
|
||||
class ClerkAuth(AuthPlugin[ClerkAuthConfig]):
|
||||
"""Contribute a `ClerkProvider` as the server's auth provider."""
|
||||
|
||||
Config: ClassVar[type[ClerkAuthConfig]] = ClerkAuthConfig
|
||||
|
||||
meta = PluginMeta(name="clerk-auth")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: ClerkAuthConfig | dict[str, Any] | None = None,
|
||||
*,
|
||||
client_storage: AsyncKeyValue | None = None,
|
||||
http_client: httpx.AsyncClient | None = None,
|
||||
) -> None:
|
||||
super().__init__(config)
|
||||
self._client_storage = client_storage
|
||||
self._http_client = http_client
|
||||
|
||||
def auth(self) -> AuthProvider | None:
|
||||
self._require("domain", "client_id", "base_url")
|
||||
self._require_one("client_secret", "jwt_signing_key")
|
||||
return ClerkProvider(
|
||||
**self._kwargs(
|
||||
"domain",
|
||||
"client_id",
|
||||
"client_secret",
|
||||
"base_url",
|
||||
"resource_base_url",
|
||||
"issuer_url",
|
||||
"redirect_path",
|
||||
"required_scopes",
|
||||
"valid_scopes",
|
||||
"timeout_seconds",
|
||||
"allowed_client_redirect_uris",
|
||||
"jwt_signing_key",
|
||||
"require_authorization_consent",
|
||||
"consent_csp_policy",
|
||||
"forward_resource",
|
||||
"extra_authorize_params",
|
||||
"enable_cimd",
|
||||
),
|
||||
client_storage=self._client_storage,
|
||||
http_client=self._http_client,
|
||||
)
|
||||
388
src/fastmcp/server/plugins/auth/clerk/provider.py
Normal file
388
src/fastmcp/server/plugins/auth/clerk/provider.py
Normal file
|
|
@ -0,0 +1,388 @@
|
|||
"""Clerk OAuth provider for FastMCP.
|
||||
|
||||
This module provides a complete Clerk OAuth integration that's ready to use
|
||||
with a Clerk domain, client ID, and client secret. It handles all the complexity
|
||||
of Clerk's OAuth/OIDC flow, token validation, and user management.
|
||||
|
||||
Clerk uses standard OIDC endpoints derived from the instance domain
|
||||
(e.g., ``https://<instance>.clerk.accounts.dev``). Token verification is
|
||||
performed via the introspection endpoint (RFC 7662) for security-critical
|
||||
checks (active status, audience, scopes), followed by the userinfo endpoint
|
||||
for profile enrichment. Userinfo failure is non-fatal.
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.plugins.auth.clerk.provider import ClerkProvider
|
||||
|
||||
auth = ClerkProvider(
|
||||
domain="saving-primate-16.clerk.accounts.dev",
|
||||
client_id="your-clerk-client-id",
|
||||
client_secret="your-clerk-client-secret",
|
||||
base_url="https://my-server.com",
|
||||
)
|
||||
|
||||
mcp = FastMCP("My Protected Server", auth=auth)
|
||||
```
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
from typing import Literal
|
||||
|
||||
import httpx
|
||||
from key_value.aio.protocols import AsyncKeyValue
|
||||
from pydantic import AnyHttpUrl
|
||||
|
||||
from fastmcp.server.auth import TokenVerifier
|
||||
from fastmcp.server.auth.auth import AccessToken
|
||||
from fastmcp.server.auth.oauth_proxy import OAuthProxy
|
||||
from fastmcp.utilities.auth import parse_scopes
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class ClerkTokenVerifier(TokenVerifier):
|
||||
"""Token verifier for Clerk OAuth tokens.
|
||||
|
||||
Clerk issues standard OIDC tokens. Verification uses the introspection
|
||||
endpoint (RFC 7662) as the primary security gate — it confirms the token
|
||||
is active and provides metadata (scopes, expiry, audience). The userinfo
|
||||
endpoint is called second for profile enrichment (name, email, picture)
|
||||
and its failure is non-fatal.
|
||||
|
||||
When a ``client_id`` is configured, the audience from introspection is
|
||||
validated against it. When ``required_scopes`` are configured,
|
||||
introspection must return the token's scopes — the verifier will not
|
||||
assume scopes when introspection is unavailable.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
domain: str,
|
||||
client_id: str | None = None,
|
||||
client_secret: str | None = None,
|
||||
required_scopes: list[str] | None = None,
|
||||
timeout_seconds: int = 10,
|
||||
http_client: httpx.AsyncClient | None = None,
|
||||
):
|
||||
"""Initialize the Clerk token verifier.
|
||||
|
||||
Args:
|
||||
domain: Clerk instance domain (e.g., "saving-primate-16.clerk.accounts.dev")
|
||||
client_id: Clerk OAuth client ID, used for introspection endpoint authentication
|
||||
client_secret: Clerk OAuth client secret, used for introspection endpoint authentication
|
||||
required_scopes: Required OAuth scopes (e.g., ["openid", "email", "profile"])
|
||||
timeout_seconds: HTTP request timeout
|
||||
http_client: Optional httpx.AsyncClient for connection pooling. When provided,
|
||||
the client is reused across calls and the caller is responsible for its
|
||||
lifecycle. When None (default), a fresh client is created per call.
|
||||
"""
|
||||
super().__init__(required_scopes=required_scopes)
|
||||
self.domain = domain.rstrip("/")
|
||||
self._client_id = client_id
|
||||
self._client_secret = client_secret
|
||||
self.timeout_seconds = timeout_seconds
|
||||
self._http_client = http_client
|
||||
|
||||
self._userinfo_url = f"https://{self.domain}/oauth/userinfo"
|
||||
self._introspection_url = f"https://{self.domain}/oauth/token_info"
|
||||
|
||||
async def verify_token(self, token: str) -> AccessToken | None:
|
||||
"""Verify a Clerk OAuth token via introspection and userinfo.
|
||||
|
||||
Calls the introspection endpoint first to validate the token and
|
||||
retrieve auth metadata (active status, scopes, expiry, audience).
|
||||
If the token passes security checks, the userinfo endpoint is called
|
||||
for profile enrichment. Userinfo failure is non-fatal.
|
||||
|
||||
When a ``client_id`` is configured, the token's audience must match it.
|
||||
When ``required_scopes`` are configured, introspection must confirm
|
||||
them; tokens are rejected if scope information is unavailable.
|
||||
"""
|
||||
try:
|
||||
async with (
|
||||
contextlib.nullcontext(self._http_client)
|
||||
if self._http_client is not None
|
||||
else httpx.AsyncClient(timeout=self.timeout_seconds)
|
||||
) as client:
|
||||
# Step 1: Validate token via introspection (RFC 7662).
|
||||
# Security-critical checks (active, audience, scopes) come first.
|
||||
introspect_data_payload: dict = {"token": token}
|
||||
introspect_kwargs: dict = {
|
||||
"data": introspect_data_payload,
|
||||
"headers": {"User-Agent": "FastMCP-Clerk-OAuth"},
|
||||
}
|
||||
|
||||
if self._client_id and self._client_secret:
|
||||
introspect_kwargs["auth"] = (
|
||||
self._client_id,
|
||||
self._client_secret,
|
||||
)
|
||||
elif self._client_id:
|
||||
introspect_data_payload["client_id"] = self._client_id
|
||||
|
||||
introspect_response = await client.post(
|
||||
self._introspection_url,
|
||||
**introspect_kwargs,
|
||||
)
|
||||
|
||||
if introspect_response.status_code != 200:
|
||||
logger.debug(
|
||||
"Clerk introspection failed: %d",
|
||||
introspect_response.status_code,
|
||||
)
|
||||
return None
|
||||
|
||||
introspect_data = introspect_response.json()
|
||||
|
||||
# RFC 7662 requires the 'active' field in the response.
|
||||
# A missing field indicates a malformed response — reject.
|
||||
if "active" not in introspect_data or not introspect_data["active"]:
|
||||
logger.debug(
|
||||
"Clerk introspection: token inactive or missing 'active' field"
|
||||
)
|
||||
return None
|
||||
|
||||
scope_str = introspect_data.get("scope", "")
|
||||
token_scopes = scope_str.split() if scope_str else []
|
||||
|
||||
aud = introspect_data.get("aud") or introspect_data.get("client_id")
|
||||
|
||||
expires_at: int | None = None
|
||||
exp = introspect_data.get("exp")
|
||||
if exp is not None:
|
||||
with contextlib.suppress(ValueError, TypeError):
|
||||
expires_at = int(exp)
|
||||
|
||||
if self._client_id and aud != self._client_id:
|
||||
logger.debug(
|
||||
"Clerk token audience mismatch: got %s, expected %s",
|
||||
aud,
|
||||
self._client_id,
|
||||
)
|
||||
return None
|
||||
|
||||
if self.required_scopes:
|
||||
if not token_scopes:
|
||||
logger.debug(
|
||||
"Clerk token missing scope information; "
|
||||
"cannot verify required scopes %s",
|
||||
self.required_scopes,
|
||||
)
|
||||
return None
|
||||
token_scopes_set = set(token_scopes)
|
||||
required_scopes_set = set(self.required_scopes)
|
||||
if not required_scopes_set.issubset(token_scopes_set):
|
||||
logger.debug(
|
||||
"Clerk token missing required scopes. Has %s, needs %s",
|
||||
token_scopes_set,
|
||||
required_scopes_set,
|
||||
)
|
||||
return None
|
||||
|
||||
# Step 2: Fetch user profile via userinfo.
|
||||
# Enriches the token with profile data (name, email, picture).
|
||||
sub = introspect_data.get("sub")
|
||||
user_data: dict = {}
|
||||
try:
|
||||
userinfo_response = await client.get(
|
||||
self._userinfo_url,
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"User-Agent": "FastMCP-Clerk-OAuth",
|
||||
},
|
||||
)
|
||||
if userinfo_response.status_code == 200:
|
||||
user_data = userinfo_response.json()
|
||||
if not sub:
|
||||
sub = user_data.get("sub")
|
||||
except Exception as e:
|
||||
logger.debug("Clerk userinfo call failed: %s", e)
|
||||
|
||||
if not sub:
|
||||
logger.debug("Clerk token missing 'sub' claim")
|
||||
return None
|
||||
|
||||
access_token = AccessToken(
|
||||
token=token,
|
||||
client_id=aud or sub,
|
||||
scopes=token_scopes,
|
||||
expires_at=expires_at,
|
||||
claims={
|
||||
"sub": sub,
|
||||
"aud": aud,
|
||||
"email": user_data.get("email"),
|
||||
"email_verified": user_data.get("email_verified"),
|
||||
"name": user_data.get("name"),
|
||||
"picture": user_data.get("picture"),
|
||||
"given_name": user_data.get("given_name"),
|
||||
"family_name": user_data.get("family_name"),
|
||||
"preferred_username": user_data.get("preferred_username"),
|
||||
"iss": user_data.get("iss"),
|
||||
"clerk_user_data": user_data or None,
|
||||
},
|
||||
)
|
||||
logger.debug("Clerk token verified successfully for sub=%s", sub)
|
||||
return access_token
|
||||
|
||||
except httpx.RequestError as e:
|
||||
logger.debug("Failed to verify Clerk token: %s", e)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.debug("Clerk token verification error: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
class ClerkProvider(OAuthProxy):
|
||||
"""Complete Clerk OAuth provider for FastMCP.
|
||||
|
||||
This provider makes it trivial to add Clerk OAuth protection to any
|
||||
FastMCP server. Provide your Clerk instance domain, OAuth app credentials,
|
||||
and a base URL, and you're ready to go.
|
||||
|
||||
Clerk uses standard OIDC endpoints derived from the instance domain.
|
||||
All endpoint URLs are constructed automatically from the domain parameter.
|
||||
|
||||
Features:
|
||||
- Transparent OAuth proxy to Clerk
|
||||
- Automatic token validation via Clerk's userinfo & introspection APIs
|
||||
- User information extraction from Clerk's OIDC claims
|
||||
- PKCE support (S256)
|
||||
- Minimal configuration required
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.plugins.auth.clerk.provider import ClerkProvider
|
||||
|
||||
auth = ClerkProvider(
|
||||
domain="saving-primate-16.clerk.accounts.dev",
|
||||
client_id="your-clerk-client-id",
|
||||
client_secret="your-clerk-client-secret",
|
||||
base_url="https://my-server.com",
|
||||
)
|
||||
|
||||
mcp = FastMCP("My App", auth=auth)
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
domain: str,
|
||||
client_id: str,
|
||||
client_secret: str | None = None,
|
||||
base_url: AnyHttpUrl | str,
|
||||
resource_base_url: AnyHttpUrl | str | None = None,
|
||||
issuer_url: AnyHttpUrl | str | None = None,
|
||||
redirect_path: str | None = None,
|
||||
required_scopes: list[str] | None = None,
|
||||
valid_scopes: list[str] | None = None,
|
||||
timeout_seconds: int = 10,
|
||||
allowed_client_redirect_uris: list[str] | None = None,
|
||||
client_storage: AsyncKeyValue | None = None,
|
||||
jwt_signing_key: str | bytes | None = None,
|
||||
require_authorization_consent: bool | Literal["remember", "external"] = True,
|
||||
consent_csp_policy: str | None = None,
|
||||
forward_resource: bool = True,
|
||||
extra_authorize_params: dict[str, str] | None = None,
|
||||
http_client: httpx.AsyncClient | None = None,
|
||||
enable_cimd: bool = True,
|
||||
):
|
||||
"""Initialize Clerk OAuth provider.
|
||||
|
||||
Args:
|
||||
domain: Clerk instance domain (e.g., "saving-primate-16.clerk.accounts.dev").
|
||||
This is used to derive all OAuth/OIDC endpoint URLs.
|
||||
client_id: Clerk OAuth application client ID
|
||||
client_secret: Clerk OAuth application client secret.
|
||||
Optional for PKCE public clients. When omitted, jwt_signing_key must be provided.
|
||||
base_url: Public URL where OAuth endpoints will be accessible (includes any mount path)
|
||||
resource_base_url: Optional public base URL for the protected resource metadata
|
||||
and token audience. Defaults to ``base_url``.
|
||||
issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL
|
||||
to avoid 404s during discovery when mounting under a path.
|
||||
redirect_path: Redirect path configured in Clerk OAuth app (defaults to "/auth/callback")
|
||||
required_scopes: Required Clerk scopes (defaults to ["openid", "email", "profile"]).
|
||||
Clerk supports: "openid", "email", "profile", "public_metadata",
|
||||
"private_metadata", "offline_access".
|
||||
valid_scopes: All scopes that clients are allowed to request, advertised through
|
||||
well-known endpoints. Defaults to required_scopes if not provided.
|
||||
timeout_seconds: HTTP request timeout for Clerk API calls (defaults to 10)
|
||||
allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients.
|
||||
If None (default), all URIs are allowed. If empty list, no URIs are allowed.
|
||||
client_storage: Storage backend for OAuth state (client registrations, encrypted tokens).
|
||||
If None, an encrypted file store will be created in the data directory
|
||||
(derived from ``platformdirs``).
|
||||
jwt_signing_key: Secret for signing FastMCP JWT tokens (any string or bytes). If bytes
|
||||
are provided, they will be used as is. If a string is provided, it will be derived
|
||||
into a 32-byte key. If not provided, the upstream client secret will be used to
|
||||
derive a 32-byte key using PBKDF2.
|
||||
require_authorization_consent: Whether to require user consent before authorizing
|
||||
clients (default True). When "external", the built-in consent screen is skipped
|
||||
but no warning is logged, indicating that consent is handled externally by Clerk.
|
||||
consent_csp_policy: Custom CSP policy for the consent page.
|
||||
extra_authorize_params: Additional parameters to forward to Clerk's authorization
|
||||
endpoint. Example: {"prompt": "login"} to force re-authentication.
|
||||
http_client: Optional httpx.AsyncClient for connection pooling in token verification.
|
||||
When provided, the client is reused across verify_token calls and the caller
|
||||
is responsible for its lifecycle. When None (default), a fresh client is created
|
||||
per call.
|
||||
enable_cimd: Enable CIMD (Client ID Metadata Document) support for URL-based
|
||||
client IDs (default True). Set to False to disable.
|
||||
"""
|
||||
domain = domain.rstrip("/")
|
||||
|
||||
required_scopes_final = (
|
||||
parse_scopes(required_scopes)
|
||||
if required_scopes is not None
|
||||
else ["openid", "email", "profile"]
|
||||
)
|
||||
|
||||
parsed_valid_scopes = (
|
||||
parse_scopes(valid_scopes) if valid_scopes is not None else None
|
||||
)
|
||||
|
||||
token_verifier = ClerkTokenVerifier(
|
||||
domain=domain,
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
required_scopes=required_scopes_final,
|
||||
timeout_seconds=timeout_seconds,
|
||||
http_client=http_client,
|
||||
)
|
||||
|
||||
extra_authorize_params_final = (
|
||||
dict(extra_authorize_params) if extra_authorize_params else {}
|
||||
)
|
||||
|
||||
super().__init__(
|
||||
upstream_authorization_endpoint=f"https://{domain}/oauth/authorize",
|
||||
upstream_token_endpoint=f"https://{domain}/oauth/token",
|
||||
upstream_client_id=client_id,
|
||||
upstream_client_secret=client_secret,
|
||||
token_verifier=token_verifier,
|
||||
base_url=base_url,
|
||||
resource_base_url=resource_base_url,
|
||||
redirect_path=redirect_path,
|
||||
issuer_url=issuer_url or base_url,
|
||||
allowed_client_redirect_uris=allowed_client_redirect_uris,
|
||||
client_storage=client_storage,
|
||||
jwt_signing_key=jwt_signing_key,
|
||||
require_authorization_consent=require_authorization_consent,
|
||||
consent_csp_policy=consent_csp_policy,
|
||||
forward_resource=forward_resource,
|
||||
extra_authorize_params=extra_authorize_params_final or None,
|
||||
valid_scopes=parsed_valid_scopes,
|
||||
enable_cimd=enable_cimd,
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"Initialized Clerk OAuth provider for domain %s with scopes: %s",
|
||||
domain,
|
||||
required_scopes_final,
|
||||
)
|
||||
5
src/fastmcp/server/plugins/auth/descope/__init__.py
Normal file
5
src/fastmcp/server/plugins/auth/descope/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
"""Descope auth plugin."""
|
||||
|
||||
from fastmcp.server.plugins.auth.descope.plugin import DescopeAuth
|
||||
|
||||
__all__ = ["DescopeAuth"]
|
||||
55
src/fastmcp/server/plugins/auth/descope/plugin.py
Normal file
55
src/fastmcp/server/plugins/auth/descope/plugin.py
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
"""Descope auth plugin."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from pydantic import AnyHttpUrl
|
||||
|
||||
from fastmcp.server.auth import AuthProvider, TokenVerifier
|
||||
from fastmcp.server.plugins.auth._base import AuthPlugin, RemoteAuthConfig
|
||||
from fastmcp.server.plugins.auth.descope.provider import DescopeProvider
|
||||
from fastmcp.server.plugins.base import PluginMeta
|
||||
|
||||
|
||||
class DescopeAuthConfig(RemoteAuthConfig):
|
||||
"""Config model for the Descope auth plugin."""
|
||||
|
||||
config_url: AnyHttpUrl | str | None = None
|
||||
project_id: str | None = None
|
||||
descope_base_url: AnyHttpUrl | str | None = None
|
||||
|
||||
|
||||
class DescopeAuth(AuthPlugin[DescopeAuthConfig]):
|
||||
"""Contribute a `DescopeProvider` as the server's auth provider."""
|
||||
|
||||
Config: ClassVar[type[DescopeAuthConfig]] = DescopeAuthConfig
|
||||
|
||||
meta = PluginMeta(name="descope-auth")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: DescopeAuthConfig | dict[str, Any] | None = None,
|
||||
*,
|
||||
token_verifier: TokenVerifier | None = None,
|
||||
) -> None:
|
||||
super().__init__(config)
|
||||
self._token_verifier = token_verifier
|
||||
|
||||
def auth(self) -> AuthProvider | None:
|
||||
self._require("base_url")
|
||||
if self.config.config_url is None:
|
||||
self._require("project_id", "descope_base_url")
|
||||
return DescopeProvider(
|
||||
**self._kwargs(
|
||||
"base_url",
|
||||
"config_url",
|
||||
"project_id",
|
||||
"descope_base_url",
|
||||
"required_scopes",
|
||||
"scopes_supported",
|
||||
"resource_name",
|
||||
"resource_documentation",
|
||||
),
|
||||
token_verifier=self._token_verifier,
|
||||
)
|
||||
209
src/fastmcp/server/plugins/auth/descope/provider.py
Normal file
209
src/fastmcp/server/plugins/auth/descope/provider.py
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
"""Descope authentication provider for FastMCP.
|
||||
|
||||
This module provides DescopeProvider - a complete authentication solution that integrates
|
||||
with Descope's OAuth 2.1 and OpenID Connect services, supporting Dynamic Client Registration (DCR)
|
||||
for seamless MCP client authentication.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
from pydantic import AnyHttpUrl
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.routing import Route
|
||||
|
||||
from fastmcp.server.auth import RemoteAuthProvider, TokenVerifier
|
||||
from fastmcp.server.auth.providers.jwt import JWTVerifier
|
||||
from fastmcp.utilities.auth import parse_scopes
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class DescopeProvider(RemoteAuthProvider):
|
||||
"""Descope metadata provider for DCR (Dynamic Client Registration).
|
||||
|
||||
This provider implements Descope integration using metadata forwarding.
|
||||
This is the recommended approach for Descope DCR
|
||||
as it allows Descope to handle the OAuth flow directly while FastMCP acts
|
||||
as a resource server.
|
||||
|
||||
IMPORTANT SETUP REQUIREMENTS:
|
||||
|
||||
1. Create an MCP Server in Descope Console:
|
||||
- Go to the [MCP Servers page](https://app.descope.com/mcp-servers) of the Descope Console
|
||||
- Create a new MCP Server
|
||||
- Ensure that **Dynamic Client Registration (DCR)** is enabled
|
||||
- Note your Well-Known URL
|
||||
|
||||
2. Note your Well-Known URL:
|
||||
- Save your Well-Known URL from [MCP Server Settings](https://app.descope.com/mcp-servers)
|
||||
- Format: ``https://.../v1/apps/agentic/P.../M.../.well-known/openid-configuration``
|
||||
|
||||
For detailed setup instructions, see:
|
||||
https://docs.descope.com/identity-federation/inbound-apps/creating-inbound-apps#method-2-dynamic-client-registration-dcr
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastmcp.server.plugins.auth.descope.provider import DescopeProvider
|
||||
|
||||
# Create Descope metadata provider (JWT verifier created automatically)
|
||||
descope_auth = DescopeProvider(
|
||||
config_url="https://.../v1/apps/agentic/P.../M.../.well-known/openid-configuration",
|
||||
base_url="https://your-fastmcp-server.com",
|
||||
)
|
||||
|
||||
# Use with FastMCP
|
||||
mcp = FastMCP("My App", auth=descope_auth)
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
base_url: AnyHttpUrl | str,
|
||||
config_url: AnyHttpUrl | str | None = None,
|
||||
project_id: str | None = None,
|
||||
descope_base_url: AnyHttpUrl | str | None = None,
|
||||
required_scopes: list[str] | None = None,
|
||||
scopes_supported: list[str] | None = None,
|
||||
resource_name: str | None = None,
|
||||
resource_documentation: AnyHttpUrl | None = None,
|
||||
token_verifier: TokenVerifier | None = None,
|
||||
):
|
||||
"""Initialize Descope metadata provider.
|
||||
|
||||
Args:
|
||||
base_url: Public URL of this FastMCP server
|
||||
config_url: Your Descope Well-Known URL (e.g., "https://.../v1/apps/agentic/P.../M.../.well-known/openid-configuration")
|
||||
This is the new recommended way. If provided, project_id and descope_base_url are ignored.
|
||||
project_id: Your Descope Project ID (e.g., "P2abc123"). Used with descope_base_url for backwards compatibility.
|
||||
descope_base_url: Your Descope base URL (e.g., "https://api.descope.com"). Used with project_id for backwards compatibility.
|
||||
required_scopes: Optional list of scopes that must be present in validated tokens.
|
||||
These scopes will be included in the protected resource metadata.
|
||||
scopes_supported: Optional list of scopes to advertise in OAuth metadata.
|
||||
If None, uses required_scopes. Use this when the scopes clients should
|
||||
request differ from the scopes enforced on tokens.
|
||||
resource_name: Optional name for the protected resource metadata.
|
||||
resource_documentation: Optional documentation URL for the protected resource.
|
||||
token_verifier: Optional token verifier. If None, creates JWT verifier for Descope
|
||||
"""
|
||||
self.base_url = AnyHttpUrl(str(base_url).rstrip("/"))
|
||||
|
||||
# Parse scopes if provided as string
|
||||
parsed_scopes = (
|
||||
parse_scopes(required_scopes) if required_scopes is not None else None
|
||||
)
|
||||
|
||||
# Determine which API is being used
|
||||
if config_url is not None:
|
||||
# New API: use config_url
|
||||
# Strip /.well-known/openid-configuration from config_url if present
|
||||
issuer_url = str(config_url)
|
||||
if issuer_url.endswith("/.well-known/openid-configuration"):
|
||||
issuer_url = issuer_url[: -len("/.well-known/openid-configuration")]
|
||||
|
||||
# Parse the issuer URL to extract descope_base_url and project_id for other uses
|
||||
parsed_url = urlparse(issuer_url)
|
||||
path_parts = parsed_url.path.strip("/").split("/")
|
||||
|
||||
# Extract project_id from path (format: /v1/apps/agentic/P.../M...)
|
||||
if "agentic" in path_parts:
|
||||
agentic_index = path_parts.index("agentic")
|
||||
if agentic_index + 1 < len(path_parts):
|
||||
self.project_id = path_parts[agentic_index + 1]
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Could not extract project_id from config_url: {issuer_url}"
|
||||
)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Could not find 'agentic' in config_url path: {issuer_url}"
|
||||
)
|
||||
|
||||
# Extract descope_base_url (scheme + netloc)
|
||||
self.descope_base_url = f"{parsed_url.scheme}://{parsed_url.netloc}".rstrip(
|
||||
"/"
|
||||
)
|
||||
elif project_id is not None and descope_base_url is not None:
|
||||
# Old API: use project_id and descope_base_url
|
||||
self.project_id = project_id
|
||||
descope_base_url_str = str(descope_base_url).rstrip("/")
|
||||
# Ensure descope_base_url has a scheme
|
||||
if not descope_base_url_str.startswith(("http://", "https://")):
|
||||
descope_base_url_str = f"https://{descope_base_url_str}"
|
||||
self.descope_base_url = descope_base_url_str
|
||||
# Old issuer format
|
||||
issuer_url = f"{self.descope_base_url}/v1/apps/{self.project_id}"
|
||||
else:
|
||||
raise ValueError(
|
||||
"Either config_url (new API) or both project_id and descope_base_url (old API) must be provided"
|
||||
)
|
||||
|
||||
# Create default JWT verifier if none provided
|
||||
if token_verifier is None:
|
||||
token_verifier = JWTVerifier(
|
||||
jwks_uri=f"{self.descope_base_url}/{self.project_id}/.well-known/jwks.json",
|
||||
issuer=issuer_url,
|
||||
algorithm="RS256",
|
||||
audience=self.project_id,
|
||||
required_scopes=parsed_scopes,
|
||||
)
|
||||
|
||||
# Initialize RemoteAuthProvider with Descope as the authorization server
|
||||
super().__init__(
|
||||
token_verifier=token_verifier,
|
||||
authorization_servers=[AnyHttpUrl(issuer_url)],
|
||||
base_url=self.base_url,
|
||||
scopes_supported=scopes_supported,
|
||||
resource_name=resource_name,
|
||||
resource_documentation=resource_documentation,
|
||||
)
|
||||
|
||||
def get_routes(
|
||||
self,
|
||||
mcp_path: str | None = None,
|
||||
) -> list[Route]:
|
||||
"""Get OAuth routes including Descope authorization server metadata forwarding.
|
||||
|
||||
This returns the standard protected resource routes plus an authorization server
|
||||
metadata endpoint that forwards Descope's OAuth metadata to clients.
|
||||
|
||||
Args:
|
||||
mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp")
|
||||
This is used to advertise the resource URL in metadata.
|
||||
"""
|
||||
# Get the standard protected resource routes from RemoteAuthProvider
|
||||
routes = super().get_routes(mcp_path)
|
||||
|
||||
async def oauth_authorization_server_metadata(request):
|
||||
"""Forward Descope OAuth authorization server metadata with FastMCP customizations."""
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
f"{self.descope_base_url}/v1/apps/{self.project_id}/.well-known/oauth-authorization-server"
|
||||
)
|
||||
response.raise_for_status()
|
||||
metadata = response.json()
|
||||
return JSONResponse(metadata)
|
||||
except Exception as e:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "server_error",
|
||||
"error_description": f"Failed to fetch Descope metadata: {e}",
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
# Add Descope authorization server metadata forwarding
|
||||
routes.append(
|
||||
Route(
|
||||
"/.well-known/oauth-authorization-server",
|
||||
endpoint=oauth_authorization_server_metadata,
|
||||
methods=["GET"],
|
||||
)
|
||||
)
|
||||
|
||||
return routes
|
||||
5
src/fastmcp/server/plugins/auth/discord/__init__.py
Normal file
5
src/fastmcp/server/plugins/auth/discord/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
"""Discord auth plugin."""
|
||||
|
||||
from fastmcp.server.plugins.auth.discord.plugin import DiscordAuth
|
||||
|
||||
__all__ = ["DiscordAuth"]
|
||||
59
src/fastmcp/server/plugins/auth/discord/plugin.py
Normal file
59
src/fastmcp/server/plugins/auth/discord/plugin.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
"""Discord auth plugin."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
import httpx
|
||||
from key_value.aio.protocols import AsyncKeyValue
|
||||
|
||||
from fastmcp.server.auth import AuthProvider
|
||||
from fastmcp.server.plugins.auth._base import AuthPlugin, OAuthProviderConfig
|
||||
from fastmcp.server.plugins.auth.discord.provider import DiscordProvider
|
||||
from fastmcp.server.plugins.base import PluginMeta
|
||||
|
||||
|
||||
class DiscordAuthConfig(OAuthProviderConfig):
|
||||
"""Config model for the Discord auth plugin."""
|
||||
|
||||
|
||||
class DiscordAuth(AuthPlugin[DiscordAuthConfig]):
|
||||
"""Contribute a `DiscordProvider` as the server's auth provider."""
|
||||
|
||||
Config: ClassVar[type[DiscordAuthConfig]] = DiscordAuthConfig
|
||||
|
||||
meta = PluginMeta(name="discord-auth")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: DiscordAuthConfig | dict[str, Any] | None = None,
|
||||
*,
|
||||
client_storage: AsyncKeyValue | None = None,
|
||||
http_client: httpx.AsyncClient | None = None,
|
||||
) -> None:
|
||||
super().__init__(config)
|
||||
self._client_storage = client_storage
|
||||
self._http_client = http_client
|
||||
|
||||
def auth(self) -> AuthProvider | None:
|
||||
self._require("client_id", "client_secret", "base_url")
|
||||
return DiscordProvider(
|
||||
**self._kwargs(
|
||||
"client_id",
|
||||
"client_secret",
|
||||
"base_url",
|
||||
"resource_base_url",
|
||||
"issuer_url",
|
||||
"redirect_path",
|
||||
"required_scopes",
|
||||
"timeout_seconds",
|
||||
"allowed_client_redirect_uris",
|
||||
"jwt_signing_key",
|
||||
"require_authorization_consent",
|
||||
"consent_csp_policy",
|
||||
"forward_resource",
|
||||
"enable_cimd",
|
||||
),
|
||||
client_storage=self._client_storage,
|
||||
http_client=self._http_client,
|
||||
)
|
||||
288
src/fastmcp/server/plugins/auth/discord/provider.py
Normal file
288
src/fastmcp/server/plugins/auth/discord/provider.py
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
"""Discord OAuth provider for FastMCP.
|
||||
|
||||
This module provides a complete Discord OAuth integration that's ready to use
|
||||
with just a client ID and client secret. It handles all the complexity of
|
||||
Discord's OAuth flow, token validation, and user management.
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.plugins.auth.discord.provider import DiscordProvider
|
||||
|
||||
# Simple Discord OAuth protection
|
||||
auth = DiscordProvider(
|
||||
client_id="your-discord-client-id",
|
||||
client_secret="your-discord-client-secret"
|
||||
)
|
||||
|
||||
mcp = FastMCP("My Protected Server", auth=auth)
|
||||
```
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
import httpx
|
||||
from key_value.aio.protocols import AsyncKeyValue
|
||||
from pydantic import AnyHttpUrl
|
||||
|
||||
from fastmcp.server.auth import TokenVerifier
|
||||
from fastmcp.server.auth.auth import AccessToken
|
||||
from fastmcp.server.auth.oauth_proxy import OAuthProxy
|
||||
from fastmcp.utilities.auth import parse_scopes
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class DiscordTokenVerifier(TokenVerifier):
|
||||
"""Token verifier for Discord OAuth tokens.
|
||||
|
||||
Discord OAuth tokens are opaque (not JWTs), so we verify them
|
||||
by calling Discord's tokeninfo API to check if they're valid and get user info.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
expected_client_id: str,
|
||||
required_scopes: list[str] | None = None,
|
||||
timeout_seconds: int = 10,
|
||||
http_client: httpx.AsyncClient | None = None,
|
||||
):
|
||||
"""Initialize the Discord token verifier.
|
||||
|
||||
Args:
|
||||
expected_client_id: Expected Discord OAuth client ID for audience binding
|
||||
required_scopes: Required OAuth scopes (e.g., ['email'])
|
||||
timeout_seconds: HTTP request timeout
|
||||
http_client: Optional httpx.AsyncClient for connection pooling. When provided,
|
||||
the client is reused across calls and the caller is responsible for its
|
||||
lifecycle. When None (default), a fresh client is created per call.
|
||||
"""
|
||||
super().__init__(required_scopes=required_scopes)
|
||||
self.expected_client_id = expected_client_id
|
||||
self.timeout_seconds = timeout_seconds
|
||||
self._http_client = http_client
|
||||
|
||||
async def verify_token(self, token: str) -> AccessToken | None:
|
||||
"""Verify Discord OAuth token by calling Discord's tokeninfo API."""
|
||||
try:
|
||||
async with (
|
||||
contextlib.nullcontext(self._http_client)
|
||||
if self._http_client is not None
|
||||
else httpx.AsyncClient(timeout=self.timeout_seconds)
|
||||
) as client:
|
||||
# Use Discord's tokeninfo endpoint to validate the token
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"User-Agent": "FastMCP-Discord-OAuth",
|
||||
}
|
||||
response = await client.get(
|
||||
"https://discord.com/api/oauth2/@me",
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.debug(
|
||||
"Discord token verification failed: %d",
|
||||
response.status_code,
|
||||
)
|
||||
return None
|
||||
|
||||
token_info = response.json()
|
||||
|
||||
# Check if token is expired (Discord returns ISO timestamp)
|
||||
expires_str = token_info.get("expires")
|
||||
expires_at = None
|
||||
if expires_str:
|
||||
expires_dt = datetime.fromisoformat(
|
||||
expires_str.replace("Z", "+00:00")
|
||||
)
|
||||
expires_at = int(expires_dt.timestamp())
|
||||
if expires_at <= int(time.time()):
|
||||
logger.debug("Discord token has expired")
|
||||
return None
|
||||
|
||||
token_scopes = token_info.get("scopes", [])
|
||||
|
||||
# Check required scopes
|
||||
if self.required_scopes:
|
||||
token_scopes_set = set(token_scopes)
|
||||
required_scopes_set = set(self.required_scopes)
|
||||
if not required_scopes_set.issubset(token_scopes_set):
|
||||
logger.debug(
|
||||
"Discord token missing required scopes. Has %d, needs %d",
|
||||
len(token_scopes_set),
|
||||
len(required_scopes_set),
|
||||
)
|
||||
return None
|
||||
|
||||
user_data = token_info.get("user", {})
|
||||
application = token_info.get("application") or {}
|
||||
client_id = str(application.get("id", "unknown"))
|
||||
if client_id != self.expected_client_id:
|
||||
logger.debug(
|
||||
"Discord token app ID mismatch: expected %s, got %s",
|
||||
self.expected_client_id,
|
||||
client_id,
|
||||
)
|
||||
return None
|
||||
|
||||
# Create AccessToken with Discord user info
|
||||
access_token = AccessToken(
|
||||
token=token,
|
||||
client_id=client_id,
|
||||
scopes=token_scopes,
|
||||
expires_at=expires_at,
|
||||
claims={
|
||||
"sub": user_data.get("id"),
|
||||
"username": user_data.get("username"),
|
||||
"discriminator": user_data.get("discriminator"),
|
||||
"avatar": user_data.get("avatar"),
|
||||
"email": user_data.get("email"),
|
||||
"verified": user_data.get("verified"),
|
||||
"locale": user_data.get("locale"),
|
||||
"discord_user": user_data,
|
||||
"discord_token_info": token_info,
|
||||
},
|
||||
)
|
||||
logger.debug("Discord token verified successfully")
|
||||
return access_token
|
||||
|
||||
except httpx.RequestError as e:
|
||||
logger.debug("Failed to verify Discord token: %s", e)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.debug("Discord token verification error: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
class DiscordProvider(OAuthProxy):
|
||||
"""Complete Discord OAuth provider for FastMCP.
|
||||
|
||||
This provider makes it trivial to add Discord OAuth protection to any
|
||||
FastMCP server. Just provide your Discord OAuth app credentials and
|
||||
a base URL, and you're ready to go.
|
||||
|
||||
Features:
|
||||
- Transparent OAuth proxy to Discord
|
||||
- Automatic token validation via Discord's API
|
||||
- User information extraction from Discord APIs
|
||||
- Minimal configuration required
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.plugins.auth.discord.provider import DiscordProvider
|
||||
|
||||
auth = DiscordProvider(
|
||||
client_id="123456789",
|
||||
client_secret="discord-client-secret-abc123...",
|
||||
base_url="https://my-server.com"
|
||||
)
|
||||
|
||||
mcp = FastMCP("My App", auth=auth)
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
client_id: str,
|
||||
client_secret: str,
|
||||
base_url: AnyHttpUrl | str,
|
||||
resource_base_url: AnyHttpUrl | str | None = None,
|
||||
issuer_url: AnyHttpUrl | str | None = None,
|
||||
redirect_path: str | None = None,
|
||||
required_scopes: list[str] | None = None,
|
||||
timeout_seconds: int = 10,
|
||||
allowed_client_redirect_uris: list[str] | None = None,
|
||||
client_storage: AsyncKeyValue | None = None,
|
||||
jwt_signing_key: str | bytes | None = None,
|
||||
require_authorization_consent: bool | Literal["remember", "external"] = True,
|
||||
consent_csp_policy: str | None = None,
|
||||
forward_resource: bool = True,
|
||||
http_client: httpx.AsyncClient | None = None,
|
||||
enable_cimd: bool = True,
|
||||
):
|
||||
"""Initialize Discord OAuth provider.
|
||||
|
||||
Args:
|
||||
client_id: Discord OAuth client ID (e.g., "123456789")
|
||||
client_secret: Discord OAuth client secret (e.g., "S....")
|
||||
base_url: Public URL where OAuth endpoints will be accessible (includes any mount path)
|
||||
resource_base_url: Optional public base URL for the protected resource metadata
|
||||
and token audience. Defaults to ``base_url``.
|
||||
issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL
|
||||
to avoid 404s during discovery when mounting under a path.
|
||||
redirect_path: Redirect path configured in Discord OAuth app (defaults to "/auth/callback")
|
||||
required_scopes: Required Discord scopes (defaults to ["identify"]). Common scopes include:
|
||||
- "identify" for profile info (default)
|
||||
- "email" for email access
|
||||
- "guilds" for server membership info
|
||||
timeout_seconds: HTTP request timeout for Discord API calls (defaults to 10)
|
||||
allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients.
|
||||
If None (default), all URIs are allowed. If empty list, no URIs are allowed.
|
||||
client_storage: Storage backend for OAuth state (client registrations, encrypted tokens).
|
||||
If None, an encrypted file store will be created in the data directory
|
||||
(derived from `platformdirs`).
|
||||
jwt_signing_key: Secret for signing FastMCP JWT tokens (any string or bytes). If bytes are provided,
|
||||
they will be used as is. If a string is provided, it will be derived into a 32-byte key. If not
|
||||
provided, the upstream client secret will be used to derive a 32-byte key using PBKDF2.
|
||||
require_authorization_consent: Whether to require user consent before authorizing clients (default True).
|
||||
When True, users see a consent screen before being redirected to Discord.
|
||||
When False, authorization proceeds directly without user confirmation.
|
||||
When "external", the built-in consent screen is skipped but no warning is
|
||||
logged, indicating that consent is handled externally (e.g. by the upstream IdP).
|
||||
SECURITY WARNING: Only set to False for local development or testing environments.
|
||||
http_client: Optional httpx.AsyncClient for connection pooling in token verification.
|
||||
When provided, the client is reused across verify_token calls and the caller
|
||||
is responsible for its lifecycle. When None (default), a fresh client is created per call.
|
||||
enable_cimd: Enable CIMD (Client ID Metadata Document) support for URL-based
|
||||
client IDs (default True). Set to False to disable.
|
||||
"""
|
||||
# Parse scopes if provided as string
|
||||
required_scopes_final = (
|
||||
parse_scopes(required_scopes)
|
||||
if required_scopes is not None
|
||||
else ["identify"]
|
||||
)
|
||||
|
||||
# Create Discord token verifier
|
||||
token_verifier = DiscordTokenVerifier(
|
||||
expected_client_id=client_id,
|
||||
required_scopes=required_scopes_final,
|
||||
timeout_seconds=timeout_seconds,
|
||||
http_client=http_client,
|
||||
)
|
||||
|
||||
# Initialize OAuth proxy with Discord endpoints
|
||||
super().__init__(
|
||||
upstream_authorization_endpoint="https://discord.com/oauth2/authorize",
|
||||
upstream_token_endpoint="https://discord.com/api/oauth2/token",
|
||||
upstream_client_id=client_id,
|
||||
upstream_client_secret=client_secret,
|
||||
token_verifier=token_verifier,
|
||||
base_url=base_url,
|
||||
resource_base_url=resource_base_url,
|
||||
redirect_path=redirect_path,
|
||||
issuer_url=issuer_url or base_url, # Default to base_url if not specified
|
||||
allowed_client_redirect_uris=allowed_client_redirect_uris,
|
||||
client_storage=client_storage,
|
||||
jwt_signing_key=jwt_signing_key,
|
||||
require_authorization_consent=require_authorization_consent,
|
||||
consent_csp_policy=consent_csp_policy,
|
||||
forward_resource=forward_resource,
|
||||
enable_cimd=enable_cimd,
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"Initialized Discord OAuth provider for client %s with scopes: %s",
|
||||
client_id,
|
||||
required_scopes_final,
|
||||
)
|
||||
5
src/fastmcp/server/plugins/auth/github/__init__.py
Normal file
5
src/fastmcp/server/plugins/auth/github/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
"""GitHub auth plugin."""
|
||||
|
||||
from fastmcp.server.plugins.auth.github.plugin import GitHubAuth
|
||||
|
||||
__all__ = ["GitHubAuth"]
|
||||
64
src/fastmcp/server/plugins/auth/github/plugin.py
Normal file
64
src/fastmcp/server/plugins/auth/github/plugin.py
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
"""GitHub auth plugin."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
import httpx
|
||||
from key_value.aio.protocols import AsyncKeyValue
|
||||
|
||||
from fastmcp.server.auth import AuthProvider
|
||||
from fastmcp.server.plugins.auth._base import AuthPlugin, OAuthProviderConfig
|
||||
from fastmcp.server.plugins.auth.github.provider import GitHubProvider
|
||||
from fastmcp.server.plugins.base import PluginMeta
|
||||
|
||||
|
||||
class GitHubAuthConfig(OAuthProviderConfig):
|
||||
"""Config model for the GitHub auth plugin."""
|
||||
|
||||
cache_ttl_seconds: int | None = None
|
||||
max_cache_size: int | None = None
|
||||
|
||||
|
||||
class GitHubAuth(AuthPlugin[GitHubAuthConfig]):
|
||||
"""Contribute a `GitHubProvider` as the server's auth provider."""
|
||||
|
||||
Config: ClassVar[type[GitHubAuthConfig]] = GitHubAuthConfig
|
||||
|
||||
meta = PluginMeta(name="github-auth")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: GitHubAuthConfig | dict[str, Any] | None = None,
|
||||
*,
|
||||
client_storage: AsyncKeyValue | None = None,
|
||||
http_client: httpx.AsyncClient | None = None,
|
||||
) -> None:
|
||||
super().__init__(config)
|
||||
self._client_storage = client_storage
|
||||
self._http_client = http_client
|
||||
|
||||
def auth(self) -> AuthProvider | None:
|
||||
self._require("client_id", "client_secret", "base_url")
|
||||
return GitHubProvider(
|
||||
**self._kwargs(
|
||||
"client_id",
|
||||
"client_secret",
|
||||
"base_url",
|
||||
"resource_base_url",
|
||||
"issuer_url",
|
||||
"redirect_path",
|
||||
"required_scopes",
|
||||
"timeout_seconds",
|
||||
"cache_ttl_seconds",
|
||||
"max_cache_size",
|
||||
"allowed_client_redirect_uris",
|
||||
"jwt_signing_key",
|
||||
"require_authorization_consent",
|
||||
"consent_csp_policy",
|
||||
"forward_resource",
|
||||
"enable_cimd",
|
||||
),
|
||||
client_storage=self._client_storage,
|
||||
http_client=self._http_client,
|
||||
)
|
||||
303
src/fastmcp/server/plugins/auth/github/provider.py
Normal file
303
src/fastmcp/server/plugins/auth/github/provider.py
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
"""GitHub OAuth provider for FastMCP.
|
||||
|
||||
This module provides a complete GitHub OAuth integration that's ready to use
|
||||
with just a client ID and client secret. It handles all the complexity of
|
||||
GitHub's OAuth flow, token validation, and user management.
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.plugins.auth.github.provider import GitHubProvider
|
||||
|
||||
# Simple GitHub OAuth protection
|
||||
auth = GitHubProvider(
|
||||
client_id="your-github-client-id",
|
||||
client_secret="your-github-client-secret"
|
||||
)
|
||||
|
||||
mcp = FastMCP("My Protected Server", auth=auth)
|
||||
```
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
from typing import Literal
|
||||
|
||||
import httpx
|
||||
from key_value.aio.protocols import AsyncKeyValue
|
||||
from pydantic import AnyHttpUrl
|
||||
|
||||
from fastmcp.server.auth import TokenVerifier
|
||||
from fastmcp.server.auth.auth import AccessToken
|
||||
from fastmcp.server.auth.oauth_proxy import OAuthProxy
|
||||
from fastmcp.utilities.auth import parse_scopes
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
from fastmcp.utilities.token_cache import TokenCache
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class GitHubTokenVerifier(TokenVerifier):
|
||||
"""Token verifier for GitHub OAuth tokens.
|
||||
|
||||
GitHub OAuth tokens are opaque (not JWTs), so we verify them
|
||||
by calling GitHub's API to check if they're valid and get user info.
|
||||
|
||||
Caching is disabled by default. Set ``cache_ttl_seconds`` to a positive
|
||||
integer to cache successful verification results and avoid repeated
|
||||
GitHub API calls for the same token.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
required_scopes: list[str] | None = None,
|
||||
timeout_seconds: int = 10,
|
||||
cache_ttl_seconds: int | None = None,
|
||||
max_cache_size: int | None = None,
|
||||
http_client: httpx.AsyncClient | None = None,
|
||||
):
|
||||
"""Initialize the GitHub token verifier.
|
||||
|
||||
Args:
|
||||
required_scopes: Required OAuth scopes (e.g., ['user:email'])
|
||||
timeout_seconds: HTTP request timeout
|
||||
cache_ttl_seconds: How long to cache verification results in seconds.
|
||||
Caching is disabled by default (None). Set to a positive integer
|
||||
to enable (e.g., 300 for 5 minutes).
|
||||
max_cache_size: Maximum number of tokens to cache. Default: 10 000.
|
||||
http_client: Optional httpx.AsyncClient for connection pooling. When provided,
|
||||
the client is reused across calls and the caller is responsible for its
|
||||
lifecycle. When None (default), a fresh client is created per call.
|
||||
"""
|
||||
super().__init__(required_scopes=required_scopes)
|
||||
self.timeout_seconds = timeout_seconds
|
||||
self._http_client = http_client
|
||||
self._cache = TokenCache(
|
||||
ttl_seconds=cache_ttl_seconds,
|
||||
max_size=max_cache_size,
|
||||
)
|
||||
|
||||
async def verify_token(self, token: str) -> AccessToken | None:
|
||||
"""Verify GitHub OAuth token by calling GitHub API."""
|
||||
is_cached, cached_result = self._cache.get(token)
|
||||
if is_cached:
|
||||
logger.debug("GitHub token cache hit")
|
||||
return cached_result
|
||||
|
||||
try:
|
||||
async with (
|
||||
contextlib.nullcontext(self._http_client)
|
||||
if self._http_client is not None
|
||||
else httpx.AsyncClient(timeout=self.timeout_seconds)
|
||||
) as client:
|
||||
# Get token info from GitHub API
|
||||
response = await client.get(
|
||||
"https://api.github.com/user",
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Accept": "application/vnd.github.v3+json",
|
||||
"User-Agent": "FastMCP-GitHub-OAuth",
|
||||
},
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.debug(
|
||||
"GitHub token verification failed: %d - %s",
|
||||
response.status_code,
|
||||
response.text[:200],
|
||||
)
|
||||
return None
|
||||
|
||||
user_data = response.json()
|
||||
|
||||
# Get token scopes from GitHub API
|
||||
# GitHub includes scopes in the X-OAuth-Scopes header
|
||||
scopes_response = await client.get(
|
||||
"https://api.github.com/user/repos", # Any authenticated endpoint
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Accept": "application/vnd.github.v3+json",
|
||||
"User-Agent": "FastMCP-GitHub-OAuth",
|
||||
},
|
||||
)
|
||||
|
||||
# Extract scopes from X-OAuth-Scopes header if available
|
||||
scopes_verified = scopes_response.status_code == 200
|
||||
oauth_scopes_header = scopes_response.headers.get("x-oauth-scopes", "")
|
||||
token_scopes = [
|
||||
scope.strip()
|
||||
for scope in oauth_scopes_header.split(",")
|
||||
if scope.strip()
|
||||
]
|
||||
|
||||
# If no scopes in header, assume basic scopes based on successful user API call
|
||||
if not token_scopes:
|
||||
token_scopes = ["user"] # Basic scope if we can access user info
|
||||
|
||||
# Check required scopes
|
||||
if self.required_scopes:
|
||||
token_scopes_set = set(token_scopes)
|
||||
required_scopes_set = set(self.required_scopes)
|
||||
if not required_scopes_set.issubset(token_scopes_set):
|
||||
logger.debug(
|
||||
"GitHub token missing required scopes. Has %d, needs %d",
|
||||
len(token_scopes_set),
|
||||
len(required_scopes_set),
|
||||
)
|
||||
return None
|
||||
|
||||
# Create AccessToken with GitHub user info
|
||||
result = AccessToken(
|
||||
token=token,
|
||||
client_id=str(user_data.get("id", "unknown")), # Use GitHub user ID
|
||||
scopes=token_scopes,
|
||||
expires_at=None, # GitHub tokens don't typically expire
|
||||
claims={
|
||||
"sub": str(user_data["id"]),
|
||||
"login": user_data.get("login"),
|
||||
"name": user_data.get("name"),
|
||||
"email": user_data.get("email"),
|
||||
"avatar_url": user_data.get("avatar_url"),
|
||||
"github_user_data": user_data,
|
||||
},
|
||||
)
|
||||
if scopes_verified:
|
||||
self._cache.set(token, result)
|
||||
return result
|
||||
|
||||
except httpx.RequestError as e:
|
||||
logger.debug("Failed to verify GitHub token: %s", e)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.debug("GitHub token verification error: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
class GitHubProvider(OAuthProxy):
|
||||
"""Complete GitHub OAuth provider for FastMCP.
|
||||
|
||||
This provider makes it trivial to add GitHub OAuth protection to any
|
||||
FastMCP server. Just provide your GitHub OAuth app credentials and
|
||||
a base URL, and you're ready to go.
|
||||
|
||||
Features:
|
||||
- Transparent OAuth proxy to GitHub
|
||||
- Automatic token validation via GitHub API
|
||||
- User information extraction
|
||||
- Minimal configuration required
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.plugins.auth.github.provider import GitHubProvider
|
||||
|
||||
auth = GitHubProvider(
|
||||
client_id="Ov23li...",
|
||||
client_secret="abc123...",
|
||||
base_url="https://my-server.com"
|
||||
)
|
||||
|
||||
mcp = FastMCP("My App", auth=auth)
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
client_id: str,
|
||||
client_secret: str,
|
||||
base_url: AnyHttpUrl | str,
|
||||
resource_base_url: AnyHttpUrl | str | None = None,
|
||||
issuer_url: AnyHttpUrl | str | None = None,
|
||||
redirect_path: str | None = None,
|
||||
required_scopes: list[str] | None = None,
|
||||
timeout_seconds: int = 10,
|
||||
cache_ttl_seconds: int | None = None,
|
||||
max_cache_size: int | None = None,
|
||||
allowed_client_redirect_uris: list[str] | None = None,
|
||||
client_storage: AsyncKeyValue | None = None,
|
||||
jwt_signing_key: str | bytes | None = None,
|
||||
require_authorization_consent: bool | Literal["remember", "external"] = True,
|
||||
consent_csp_policy: str | None = None,
|
||||
forward_resource: bool = True,
|
||||
http_client: httpx.AsyncClient | None = None,
|
||||
enable_cimd: bool = True,
|
||||
):
|
||||
"""Initialize GitHub OAuth provider.
|
||||
|
||||
Args:
|
||||
client_id: GitHub OAuth app client ID (e.g., "Ov23li...")
|
||||
client_secret: GitHub OAuth app client secret
|
||||
base_url: Public URL where OAuth endpoints will be accessible (includes any mount path)
|
||||
resource_base_url: Optional public base URL for the protected resource metadata
|
||||
and token audience. Defaults to ``base_url``.
|
||||
issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL
|
||||
to avoid 404s during discovery when mounting under a path.
|
||||
redirect_path: Redirect path configured in GitHub OAuth app (defaults to "/auth/callback")
|
||||
required_scopes: Required GitHub scopes (defaults to ["user"])
|
||||
timeout_seconds: HTTP request timeout for GitHub API calls (defaults to 10)
|
||||
cache_ttl_seconds: How long to cache token verification results in seconds.
|
||||
Caching is disabled by default (None). Set to a positive integer to
|
||||
enable (e.g., 300 for 5 minutes).
|
||||
max_cache_size: Maximum number of tokens to cache. Default: 10 000.
|
||||
allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients.
|
||||
If None (default), all URIs are allowed. If empty list, no URIs are allowed.
|
||||
client_storage: Storage backend for OAuth state (client registrations, encrypted tokens).
|
||||
If None, an encrypted file store will be created in the data directory
|
||||
(derived from `platformdirs`).
|
||||
jwt_signing_key: Secret for signing FastMCP JWT tokens (any string or bytes). If bytes are provided,
|
||||
they will be used as is. If a string is provided, it will be derived into a 32-byte key. If not
|
||||
provided, the upstream client secret will be used to derive a 32-byte key using PBKDF2.
|
||||
require_authorization_consent: Whether to require user consent before authorizing clients (default True).
|
||||
When True, users see a consent screen before being redirected to GitHub.
|
||||
When False, authorization proceeds directly without user confirmation.
|
||||
When "external", the built-in consent screen is skipped but no warning is
|
||||
logged, indicating that consent is handled externally (e.g. by the upstream IdP).
|
||||
SECURITY WARNING: Only set to False for local development or testing environments.
|
||||
http_client: Optional httpx.AsyncClient for connection pooling in token verification.
|
||||
When provided, the client is reused across verify_token calls and the caller
|
||||
is responsible for its lifecycle. When None (default), a fresh client is created per call.
|
||||
enable_cimd: Enable CIMD (Client ID Metadata Document) support for URL-based
|
||||
client IDs (default True). Set to False to disable.
|
||||
"""
|
||||
# Parse scopes if provided as string
|
||||
required_scopes_final = (
|
||||
parse_scopes(required_scopes) if required_scopes is not None else ["user"]
|
||||
)
|
||||
|
||||
# Create GitHub token verifier
|
||||
token_verifier = GitHubTokenVerifier(
|
||||
required_scopes=required_scopes_final,
|
||||
timeout_seconds=timeout_seconds,
|
||||
cache_ttl_seconds=cache_ttl_seconds,
|
||||
max_cache_size=max_cache_size,
|
||||
http_client=http_client,
|
||||
)
|
||||
|
||||
# Initialize OAuth proxy with GitHub endpoints
|
||||
super().__init__(
|
||||
upstream_authorization_endpoint="https://github.com/login/oauth/authorize",
|
||||
upstream_token_endpoint="https://github.com/login/oauth/access_token",
|
||||
upstream_client_id=client_id,
|
||||
upstream_client_secret=client_secret,
|
||||
token_verifier=token_verifier,
|
||||
base_url=base_url,
|
||||
resource_base_url=resource_base_url,
|
||||
redirect_path=redirect_path,
|
||||
issuer_url=issuer_url or base_url, # Default to base_url if not specified
|
||||
allowed_client_redirect_uris=allowed_client_redirect_uris,
|
||||
client_storage=client_storage,
|
||||
jwt_signing_key=jwt_signing_key,
|
||||
require_authorization_consent=require_authorization_consent,
|
||||
consent_csp_policy=consent_csp_policy,
|
||||
forward_resource=forward_resource,
|
||||
enable_cimd=enable_cimd,
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"Initialized GitHub OAuth provider for client %s with scopes: %s",
|
||||
client_id,
|
||||
required_scopes_final,
|
||||
)
|
||||
5
src/fastmcp/server/plugins/auth/google/__init__.py
Normal file
5
src/fastmcp/server/plugins/auth/google/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
"""Google auth plugin."""
|
||||
|
||||
from fastmcp.server.plugins.auth.google.plugin import GoogleAuth
|
||||
|
||||
__all__ = ["GoogleAuth"]
|
||||
65
src/fastmcp/server/plugins/auth/google/plugin.py
Normal file
65
src/fastmcp/server/plugins/auth/google/plugin.py
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
"""Google auth plugin."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
import httpx
|
||||
from key_value.aio.protocols import AsyncKeyValue
|
||||
|
||||
from fastmcp.server.auth import AuthProvider
|
||||
from fastmcp.server.plugins.auth._base import AuthPlugin, OAuthProviderConfig
|
||||
from fastmcp.server.plugins.auth.google.provider import GoogleProvider
|
||||
from fastmcp.server.plugins.base import PluginMeta
|
||||
|
||||
|
||||
class GoogleAuthConfig(OAuthProviderConfig):
|
||||
"""Config model for the Google auth plugin."""
|
||||
|
||||
valid_scopes: list[str] | None = None
|
||||
extra_authorize_params: dict[str, str] | None = None
|
||||
|
||||
|
||||
class GoogleAuth(AuthPlugin[GoogleAuthConfig]):
|
||||
"""Contribute a `GoogleProvider` as the server's auth provider."""
|
||||
|
||||
Config: ClassVar[type[GoogleAuthConfig]] = GoogleAuthConfig
|
||||
|
||||
meta = PluginMeta(name="google-auth")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: GoogleAuthConfig | dict[str, Any] | None = None,
|
||||
*,
|
||||
client_storage: AsyncKeyValue | None = None,
|
||||
http_client: httpx.AsyncClient | None = None,
|
||||
) -> None:
|
||||
super().__init__(config)
|
||||
self._client_storage = client_storage
|
||||
self._http_client = http_client
|
||||
|
||||
def auth(self) -> AuthProvider | None:
|
||||
self._require("client_id", "base_url")
|
||||
self._require_one("client_secret", "jwt_signing_key")
|
||||
return GoogleProvider(
|
||||
**self._kwargs(
|
||||
"client_id",
|
||||
"client_secret",
|
||||
"base_url",
|
||||
"resource_base_url",
|
||||
"issuer_url",
|
||||
"redirect_path",
|
||||
"required_scopes",
|
||||
"valid_scopes",
|
||||
"timeout_seconds",
|
||||
"allowed_client_redirect_uris",
|
||||
"jwt_signing_key",
|
||||
"require_authorization_consent",
|
||||
"consent_csp_policy",
|
||||
"forward_resource",
|
||||
"extra_authorize_params",
|
||||
"enable_cimd",
|
||||
),
|
||||
client_storage=self._client_storage,
|
||||
http_client=self._http_client,
|
||||
)
|
||||
365
src/fastmcp/server/plugins/auth/google/provider.py
Normal file
365
src/fastmcp/server/plugins/auth/google/provider.py
Normal file
|
|
@ -0,0 +1,365 @@
|
|||
"""Google OAuth provider for FastMCP.
|
||||
|
||||
This module provides a complete Google OAuth integration that's ready to use
|
||||
with just a client ID and client secret. It handles all the complexity of
|
||||
Google's OAuth flow, token validation, and user management.
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.plugins.auth.google.provider import GoogleProvider
|
||||
|
||||
# Simple Google OAuth protection
|
||||
auth = GoogleProvider(
|
||||
client_id="your-google-client-id.apps.googleusercontent.com",
|
||||
client_secret="your-google-client-secret"
|
||||
)
|
||||
|
||||
mcp = FastMCP("My Protected Server", auth=auth)
|
||||
```
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import time
|
||||
from typing import Literal
|
||||
|
||||
import httpx
|
||||
from key_value.aio.protocols import AsyncKeyValue
|
||||
from pydantic import AnyHttpUrl
|
||||
|
||||
from fastmcp.server.auth import TokenVerifier
|
||||
from fastmcp.server.auth.auth import AccessToken
|
||||
from fastmcp.server.auth.oauth_proxy import OAuthProxy
|
||||
from fastmcp.utilities.auth import parse_scopes
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
GOOGLE_SCOPE_ALIASES: dict[str, str] = {
|
||||
"email": "https://www.googleapis.com/auth/userinfo.email",
|
||||
"profile": "https://www.googleapis.com/auth/userinfo.profile",
|
||||
}
|
||||
|
||||
|
||||
def _normalize_google_scope(scope: str) -> str:
|
||||
"""Normalize a Google scope shorthand to its canonical full URI.
|
||||
|
||||
Google accepts shorthand scopes like "email" and "profile" in authorization
|
||||
requests, but returns the full URI form in token responses. This normalizes
|
||||
to the full URI so comparisons work regardless of which form was used.
|
||||
"""
|
||||
return GOOGLE_SCOPE_ALIASES.get(scope, scope)
|
||||
|
||||
|
||||
class GoogleTokenVerifier(TokenVerifier):
|
||||
"""Token verifier for Google OAuth tokens.
|
||||
|
||||
Google OAuth tokens are opaque (not JWTs), so we verify them by calling
|
||||
Google's tokeninfo endpoint with the access token as a query parameter.
|
||||
This returns the OAuth app ID (``aud``), granted scopes, and expiry time.
|
||||
User profile data (name, picture, etc.) is fetched separately from the
|
||||
v2 userinfo endpoint when the token is valid.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
required_scopes: list[str] | None = None,
|
||||
timeout_seconds: int = 10,
|
||||
http_client: httpx.AsyncClient | None = None,
|
||||
):
|
||||
"""Initialize the Google token verifier.
|
||||
|
||||
Args:
|
||||
required_scopes: Required OAuth scopes (e.g., ['openid', 'https://www.googleapis.com/auth/userinfo.email'])
|
||||
timeout_seconds: HTTP request timeout
|
||||
http_client: Optional httpx.AsyncClient for connection pooling. When provided,
|
||||
the client is reused across calls and the caller is responsible for its
|
||||
lifecycle. When None (default), a fresh client is created per call.
|
||||
"""
|
||||
normalized = (
|
||||
[_normalize_google_scope(s) for s in required_scopes]
|
||||
if required_scopes
|
||||
else required_scopes
|
||||
)
|
||||
super().__init__(required_scopes=normalized)
|
||||
self.timeout_seconds = timeout_seconds
|
||||
self._http_client = http_client
|
||||
|
||||
async def verify_token(self, token: str) -> AccessToken | None:
|
||||
"""Verify a Google OAuth token using the tokeninfo endpoint.
|
||||
|
||||
Calls ``https://oauth2.googleapis.com/tokeninfo?access_token=TOKEN``
|
||||
to validate the token and retrieve the OAuth app ID (``aud``), granted
|
||||
scopes, and expiry time. On success, fetches user profile data from
|
||||
the v2 userinfo endpoint to populate name, picture, and locale claims.
|
||||
"""
|
||||
try:
|
||||
async with (
|
||||
contextlib.nullcontext(self._http_client)
|
||||
if self._http_client is not None
|
||||
else httpx.AsyncClient(timeout=self.timeout_seconds)
|
||||
) as client:
|
||||
# Step 1: Verify token via tokeninfo endpoint.
|
||||
# Returns aud (OAuth app ID), scope (space-separated), expires_in, sub, email.
|
||||
response = await client.get(
|
||||
"https://oauth2.googleapis.com/tokeninfo",
|
||||
params={"access_token": token},
|
||||
headers={"User-Agent": "FastMCP-Google-OAuth"},
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.debug(
|
||||
"Google token verification failed: %d",
|
||||
response.status_code,
|
||||
)
|
||||
return None
|
||||
|
||||
token_data = response.json()
|
||||
|
||||
# aud is the OAuth app ID (client_id / audience)
|
||||
aud = token_data.get("aud")
|
||||
if not aud:
|
||||
logger.debug("Google tokeninfo missing 'aud' claim")
|
||||
return None
|
||||
|
||||
# sub is required (unique Google user ID)
|
||||
sub = token_data.get("sub")
|
||||
if not sub:
|
||||
logger.debug("Google tokeninfo missing 'sub' claim")
|
||||
return None
|
||||
|
||||
# Parse scopes directly from the tokeninfo response (space-separated)
|
||||
scope_str = token_data.get("scope", "")
|
||||
token_scopes = scope_str.split() if scope_str else []
|
||||
|
||||
# Check required scopes
|
||||
if self.required_scopes:
|
||||
token_scopes_set = set(token_scopes)
|
||||
required_scopes_set = set(self.required_scopes)
|
||||
if not required_scopes_set.issubset(token_scopes_set):
|
||||
logger.debug(
|
||||
"Google token missing required scopes. Has %d, needs %d",
|
||||
len(token_scopes_set),
|
||||
len(required_scopes_set),
|
||||
)
|
||||
return None
|
||||
|
||||
# Compute expiry from expires_in (seconds until expiry)
|
||||
expires_at: int | None = None
|
||||
expires_in = token_data.get("expires_in")
|
||||
if expires_in is not None:
|
||||
with contextlib.suppress(ValueError, TypeError):
|
||||
expires_at = int(time.time()) + int(expires_in)
|
||||
|
||||
# Step 2: Fetch user profile from v2 userinfo endpoint.
|
||||
# tokeninfo provides auth data; userinfo provides name, picture, locale.
|
||||
user_data: dict = {}
|
||||
try:
|
||||
userinfo_response = await client.get(
|
||||
"https://www.googleapis.com/oauth2/v2/userinfo",
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"User-Agent": "FastMCP-Google-OAuth",
|
||||
},
|
||||
)
|
||||
if userinfo_response.status_code == 200:
|
||||
user_data = userinfo_response.json()
|
||||
except Exception as e:
|
||||
logger.debug("Failed to fetch Google user profile: %s", e)
|
||||
|
||||
access_token = AccessToken(
|
||||
token=token,
|
||||
client_id=sub,
|
||||
scopes=token_scopes,
|
||||
expires_at=expires_at,
|
||||
claims={
|
||||
"sub": sub,
|
||||
"aud": aud,
|
||||
"email": token_data.get("email") or user_data.get("email"),
|
||||
"email_verified": token_data.get("email_verified")
|
||||
or user_data.get("verified_email"),
|
||||
"name": user_data.get("name"),
|
||||
"picture": user_data.get("picture"),
|
||||
"given_name": user_data.get("given_name"),
|
||||
"family_name": user_data.get("family_name"),
|
||||
"locale": user_data.get("locale"),
|
||||
"google_user_data": user_data or None,
|
||||
},
|
||||
)
|
||||
logger.debug("Google token verified successfully")
|
||||
return access_token
|
||||
|
||||
except httpx.RequestError as e:
|
||||
logger.debug("Failed to verify Google token: %s", e)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.debug("Google token verification error: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
class GoogleProvider(OAuthProxy):
|
||||
"""Complete Google OAuth provider for FastMCP.
|
||||
|
||||
This provider makes it trivial to add Google OAuth protection to any
|
||||
FastMCP server. Just provide your Google OAuth app credentials and
|
||||
a base URL, and you're ready to go.
|
||||
|
||||
Features:
|
||||
- Transparent OAuth proxy to Google
|
||||
- Automatic token validation via Google's tokeninfo API
|
||||
- User information extraction from Google APIs
|
||||
- Minimal configuration required
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.plugins.auth.google.provider import GoogleProvider
|
||||
|
||||
auth = GoogleProvider(
|
||||
client_id="123456789.apps.googleusercontent.com",
|
||||
client_secret="GOCSPX-abc123...",
|
||||
base_url="https://my-server.com"
|
||||
)
|
||||
|
||||
mcp = FastMCP("My App", auth=auth)
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
client_id: str,
|
||||
client_secret: str | None = None,
|
||||
base_url: AnyHttpUrl | str,
|
||||
resource_base_url: AnyHttpUrl | str | None = None,
|
||||
issuer_url: AnyHttpUrl | str | None = None,
|
||||
redirect_path: str | None = None,
|
||||
required_scopes: list[str] | None = None,
|
||||
valid_scopes: list[str] | None = None,
|
||||
timeout_seconds: int = 10,
|
||||
allowed_client_redirect_uris: list[str] | None = None,
|
||||
client_storage: AsyncKeyValue | None = None,
|
||||
jwt_signing_key: str | bytes | None = None,
|
||||
require_authorization_consent: bool | Literal["remember", "external"] = True,
|
||||
consent_csp_policy: str | None = None,
|
||||
forward_resource: bool = True,
|
||||
extra_authorize_params: dict[str, str] | None = None,
|
||||
http_client: httpx.AsyncClient | None = None,
|
||||
enable_cimd: bool = True,
|
||||
):
|
||||
"""Initialize Google OAuth provider.
|
||||
|
||||
Args:
|
||||
client_id: Google OAuth client ID (e.g., "123456789.apps.googleusercontent.com")
|
||||
client_secret: Google OAuth client secret (e.g., "GOCSPX-abc123...").
|
||||
Optional for PKCE public clients (e.g., native apps). When omitted,
|
||||
jwt_signing_key must be provided.
|
||||
base_url: Public URL where OAuth endpoints will be accessible (includes any mount path)
|
||||
resource_base_url: Optional public base URL for the protected resource metadata
|
||||
and token audience. Defaults to ``base_url``.
|
||||
issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL
|
||||
to avoid 404s during discovery when mounting under a path.
|
||||
redirect_path: Redirect path configured in Google OAuth app (defaults to "/auth/callback")
|
||||
required_scopes: Required Google scopes (defaults to ["openid"]). Common scopes include:
|
||||
- "openid" for OpenID Connect (default)
|
||||
- "https://www.googleapis.com/auth/userinfo.email" for email access
|
||||
- "https://www.googleapis.com/auth/userinfo.profile" for profile info
|
||||
Google scope shorthands like "email" and "profile" are automatically
|
||||
normalized to their full URI forms for token verification.
|
||||
valid_scopes: All scopes that clients are allowed to request, advertised through
|
||||
well-known endpoints. Defaults to required_scopes if not provided. Use this
|
||||
when you want clients to be able to request additional scopes beyond the
|
||||
required minimum. Shorthands are normalized to full URI forms.
|
||||
timeout_seconds: HTTP request timeout for Google API calls (defaults to 10)
|
||||
allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients.
|
||||
If None (default), all URIs are allowed. If empty list, no URIs are allowed.
|
||||
client_storage: Storage backend for OAuth state (client registrations, encrypted tokens).
|
||||
If None, an encrypted file store will be created in the data directory
|
||||
(derived from `platformdirs`).
|
||||
jwt_signing_key: Secret for signing FastMCP JWT tokens (any string or bytes). If bytes are provided,
|
||||
they will be used as is. If a string is provided, it will be derived into a 32-byte key. If not
|
||||
provided, the upstream client secret will be used to derive a 32-byte key using PBKDF2.
|
||||
require_authorization_consent: Whether to require user consent before authorizing clients (default True).
|
||||
When True, users see a consent screen before being redirected to Google.
|
||||
When False, authorization proceeds directly without user confirmation.
|
||||
When "external", the built-in consent screen is skipped but no warning is
|
||||
logged, indicating that consent is handled externally (e.g. by Google's own consent).
|
||||
SECURITY WARNING: Only set to False for local development or testing environments.
|
||||
extra_authorize_params: Additional parameters to forward to Google's authorization endpoint.
|
||||
By default, GoogleProvider sets {"access_type": "offline", "prompt": "consent"} to ensure
|
||||
refresh tokens are returned. You can override these defaults or add additional parameters.
|
||||
Example: {"prompt": "select_account"} to let users choose their Google account.
|
||||
http_client: Optional httpx.AsyncClient for connection pooling in token verification.
|
||||
When provided, the client is reused across verify_token calls and the caller
|
||||
is responsible for its lifecycle. When None (default), a fresh client is created per call.
|
||||
enable_cimd: Enable CIMD (Client ID Metadata Document) support for URL-based
|
||||
client IDs (default True). Set to False to disable.
|
||||
"""
|
||||
# Parse scopes if provided as string
|
||||
# Google requires at least one scope - openid is the minimal OIDC scope
|
||||
required_scopes_final = (
|
||||
parse_scopes(required_scopes) if required_scopes is not None else ["openid"]
|
||||
)
|
||||
|
||||
# Normalize valid_scopes if provided
|
||||
parsed_valid_scopes = (
|
||||
parse_scopes(valid_scopes) if valid_scopes is not None else None
|
||||
)
|
||||
valid_scopes_final = (
|
||||
[_normalize_google_scope(s) for s in parsed_valid_scopes]
|
||||
if parsed_valid_scopes is not None
|
||||
else None
|
||||
)
|
||||
|
||||
# Create Google token verifier
|
||||
# Normalization of shorthand scopes (e.g. "email" -> full URI) happens
|
||||
# inside GoogleTokenVerifier so required_scopes match what Google returns.
|
||||
token_verifier = GoogleTokenVerifier(
|
||||
required_scopes=required_scopes_final,
|
||||
timeout_seconds=timeout_seconds,
|
||||
http_client=http_client,
|
||||
)
|
||||
|
||||
# Set Google-specific defaults for extra authorize params
|
||||
# access_type=offline ensures refresh tokens are returned
|
||||
# prompt=consent forces consent screen to get refresh token (Google only issues on first auth otherwise)
|
||||
google_defaults = {
|
||||
"access_type": "offline",
|
||||
"prompt": "consent",
|
||||
}
|
||||
# User-provided params override defaults
|
||||
if extra_authorize_params:
|
||||
google_defaults.update(extra_authorize_params)
|
||||
extra_authorize_params_final = google_defaults
|
||||
|
||||
# Initialize OAuth proxy with Google endpoints
|
||||
super().__init__(
|
||||
upstream_authorization_endpoint="https://accounts.google.com/o/oauth2/v2/auth",
|
||||
upstream_token_endpoint="https://oauth2.googleapis.com/token",
|
||||
upstream_client_id=client_id,
|
||||
upstream_client_secret=client_secret,
|
||||
token_verifier=token_verifier,
|
||||
base_url=base_url,
|
||||
resource_base_url=resource_base_url,
|
||||
redirect_path=redirect_path,
|
||||
issuer_url=issuer_url or base_url, # Default to base_url if not specified
|
||||
allowed_client_redirect_uris=allowed_client_redirect_uris,
|
||||
client_storage=client_storage,
|
||||
jwt_signing_key=jwt_signing_key,
|
||||
require_authorization_consent=require_authorization_consent,
|
||||
consent_csp_policy=consent_csp_policy,
|
||||
forward_resource=forward_resource,
|
||||
extra_authorize_params=extra_authorize_params_final,
|
||||
valid_scopes=valid_scopes_final,
|
||||
enable_cimd=enable_cimd,
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"Initialized Google OAuth provider for client %s with scopes: %s",
|
||||
client_id,
|
||||
required_scopes_final,
|
||||
)
|
||||
5
src/fastmcp/server/plugins/auth/keycloak/__init__.py
Normal file
5
src/fastmcp/server/plugins/auth/keycloak/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
"""Keycloak auth plugin."""
|
||||
|
||||
from fastmcp.server.plugins.auth.keycloak.plugin import KeycloakAuth
|
||||
|
||||
__all__ = ["KeycloakAuth"]
|
||||
45
src/fastmcp/server/plugins/auth/keycloak/plugin.py
Normal file
45
src/fastmcp/server/plugins/auth/keycloak/plugin.py
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
"""Keycloak auth plugin."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from pydantic import AnyHttpUrl
|
||||
|
||||
from fastmcp.server.auth import AuthProvider, TokenVerifier
|
||||
from fastmcp.server.plugins.auth._base import AuthPlugin, PluginConfig
|
||||
from fastmcp.server.plugins.auth.keycloak.provider import KeycloakAuthProvider
|
||||
from fastmcp.server.plugins.base import PluginMeta
|
||||
|
||||
|
||||
class KeycloakAuthConfig(PluginConfig):
|
||||
"""Config model for the Keycloak auth plugin."""
|
||||
|
||||
realm_url: AnyHttpUrl | str | None = None
|
||||
base_url: AnyHttpUrl | str | None = None
|
||||
required_scopes: list[str] | str | None = None
|
||||
audience: str | list[str] | None = None
|
||||
|
||||
|
||||
class KeycloakAuth(AuthPlugin[KeycloakAuthConfig]):
|
||||
"""Contribute a `KeycloakAuthProvider` as the server's auth provider."""
|
||||
|
||||
Config: ClassVar[type[KeycloakAuthConfig]] = KeycloakAuthConfig
|
||||
|
||||
meta = PluginMeta(name="keycloak-auth")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: KeycloakAuthConfig | dict[str, Any] | None = None,
|
||||
*,
|
||||
token_verifier: TokenVerifier | None = None,
|
||||
) -> None:
|
||||
super().__init__(config)
|
||||
self._token_verifier = token_verifier
|
||||
|
||||
def auth(self) -> AuthProvider | None:
|
||||
self._require("realm_url", "base_url")
|
||||
return KeycloakAuthProvider(
|
||||
**self._kwargs("realm_url", "base_url", "required_scopes", "audience"),
|
||||
token_verifier=self._token_verifier,
|
||||
)
|
||||
74
src/fastmcp/server/plugins/auth/keycloak/provider.py
Normal file
74
src/fastmcp/server/plugins/auth/keycloak/provider.py
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
"""Keycloak authentication provider for FastMCP."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import AnyHttpUrl
|
||||
|
||||
from fastmcp.server.auth import RemoteAuthProvider, TokenVerifier
|
||||
from fastmcp.server.auth.providers.jwt import JWTVerifier
|
||||
from fastmcp.utilities.auth import parse_scopes
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class KeycloakAuthProvider(RemoteAuthProvider):
|
||||
"""Keycloak authentication provider using Dynamic Client Registration (DCR).
|
||||
|
||||
Requires Keycloak 26.6.0 or later, which includes the fix for DCR compatibility
|
||||
with MCP clients (https://github.com/keycloak/keycloak/pull/45309).
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.plugins.auth.keycloak.provider import KeycloakAuthProvider
|
||||
|
||||
auth = KeycloakAuthProvider(
|
||||
realm_url="https://keycloak.example.com/realms/myrealm",
|
||||
base_url="https://my-mcp-server.example.com",
|
||||
)
|
||||
|
||||
mcp = FastMCP("My App", auth=auth)
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
realm_url: AnyHttpUrl | str,
|
||||
base_url: AnyHttpUrl | str,
|
||||
required_scopes: list[str] | str | None = None,
|
||||
audience: str | list[str] | None = None,
|
||||
token_verifier: TokenVerifier | None = None,
|
||||
):
|
||||
"""Initialize the Keycloak auth provider.
|
||||
|
||||
Args:
|
||||
realm_url: Keycloak realm URL (e.g., "https://keycloak.example.com/realms/myrealm")
|
||||
base_url: Public URL of this FastMCP server
|
||||
required_scopes: Scopes to require on incoming tokens. Defaults to
|
||||
["openid"], which ensures the `sub` claim (user identifier) is
|
||||
present in the access token. Override to require additional scopes.
|
||||
audience: Optional audience(s) for JWT validation. Recommended for production.
|
||||
token_verifier: Optional custom token verifier. Defaults to a JWTVerifier
|
||||
configured for Keycloak's JWKS endpoint and issuer.
|
||||
"""
|
||||
self.realm_url = str(realm_url).rstrip("/")
|
||||
parsed_scopes = (
|
||||
parse_scopes(required_scopes) if required_scopes is not None else ["openid"]
|
||||
)
|
||||
|
||||
if token_verifier is None:
|
||||
token_verifier = JWTVerifier(
|
||||
jwks_uri=f"{self.realm_url}/protocol/openid-connect/certs",
|
||||
issuer=self.realm_url,
|
||||
algorithm="RS256",
|
||||
required_scopes=parsed_scopes,
|
||||
audience=audience,
|
||||
)
|
||||
|
||||
super().__init__(
|
||||
token_verifier=token_verifier,
|
||||
authorization_servers=[AnyHttpUrl(self.realm_url)],
|
||||
base_url=AnyHttpUrl(str(base_url).rstrip("/")),
|
||||
)
|
||||
5
src/fastmcp/server/plugins/auth/oci/__init__.py
Normal file
5
src/fastmcp/server/plugins/auth/oci/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
"""OCI auth plugin."""
|
||||
|
||||
from fastmcp.server.plugins.auth.oci.plugin import OCIAuth
|
||||
|
||||
__all__ = ["OCIAuth"]
|
||||
61
src/fastmcp/server/plugins/auth/oci/plugin.py
Normal file
61
src/fastmcp/server/plugins/auth/oci/plugin.py
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
"""OCI auth plugin."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from key_value.aio.protocols import AsyncKeyValue
|
||||
from pydantic import AnyHttpUrl
|
||||
|
||||
from fastmcp.server.auth import AuthProvider
|
||||
from fastmcp.server.plugins.auth._base import AuthPlugin, OAuthProxyConfig
|
||||
from fastmcp.server.plugins.auth.oci.provider import OCIProvider
|
||||
from fastmcp.server.plugins.base import PluginMeta
|
||||
|
||||
|
||||
class OCIAuthConfig(OAuthProxyConfig):
|
||||
"""Config model for the OCI auth plugin."""
|
||||
|
||||
config_url: AnyHttpUrl | str | None = None
|
||||
client_id: str | None = None
|
||||
client_secret: str | None = None
|
||||
audience: str | None = None
|
||||
|
||||
|
||||
class OCIAuth(AuthPlugin[OCIAuthConfig]):
|
||||
"""Contribute an `OCIProvider` as the server's auth provider."""
|
||||
|
||||
Config: ClassVar[type[OCIAuthConfig]] = OCIAuthConfig
|
||||
|
||||
meta = PluginMeta(name="oci-auth")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: OCIAuthConfig | dict[str, Any] | None = None,
|
||||
*,
|
||||
client_storage: AsyncKeyValue | None = None,
|
||||
) -> None:
|
||||
super().__init__(config)
|
||||
self._client_storage = client_storage
|
||||
|
||||
def auth(self) -> AuthProvider | None:
|
||||
self._require("config_url", "client_id", "client_secret", "base_url")
|
||||
return OCIProvider(
|
||||
**self._kwargs(
|
||||
"config_url",
|
||||
"client_id",
|
||||
"client_secret",
|
||||
"base_url",
|
||||
"resource_base_url",
|
||||
"audience",
|
||||
"issuer_url",
|
||||
"required_scopes",
|
||||
"redirect_path",
|
||||
"allowed_client_redirect_uris",
|
||||
"jwt_signing_key",
|
||||
"require_authorization_consent",
|
||||
"consent_csp_policy",
|
||||
"forward_resource",
|
||||
),
|
||||
client_storage=self._client_storage,
|
||||
)
|
||||
180
src/fastmcp/server/plugins/auth/oci/provider.py
Normal file
180
src/fastmcp/server/plugins/auth/oci/provider.py
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
"""OCI OIDC provider for FastMCP.
|
||||
|
||||
The pull request for the provider is submitted to fastmcp.
|
||||
|
||||
This module provides OIDC Implementation to integrate MCP servers with OCI.
|
||||
You only need OCI Identity Domain's discovery URL, client ID, client secret, and base URL.
|
||||
|
||||
Post Authentication, you get OCI IAM domain access token. That is not authorized to invoke OCI control plane.
|
||||
You need to exchange the IAM domain access token for OCI UPST token to invoke OCI control plane APIs.
|
||||
The sample code below has get_oci_signer function that returns OCI TokenExchangeSigner object.
|
||||
You can use the signer object to create OCI service object.
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.plugins.auth.oci.provider import OCIProvider
|
||||
from fastmcp.server.dependencies import get_access_token
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
|
||||
import os
|
||||
|
||||
import oci
|
||||
from oci.auth.signers import TokenExchangeSigner
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Load configuration from environment
|
||||
config_url = os.environ.get("OCI_CONFIG_URL") # OCI IAM Domain OIDC discovery URL
|
||||
client_id = os.environ.get("OCI_CLIENT_ID") # Client ID configured for the OCI IAM Domain Integrated Application
|
||||
client_secret = os.environ.get("OCI_CLIENT_SECRET") # Client secret configured for the OCI IAM Domain Integrated Application
|
||||
iam_guid = os.environ.get("OCI_IAM_GUID") # IAM GUID configured for the OCI IAM Domain
|
||||
|
||||
# Simple OCI OIDC protection
|
||||
auth = OCIProvider(
|
||||
config_url=config_url, # config URL is the OCI IAM Domain OIDC discovery URL
|
||||
client_id=client_id, # This is same as the client ID configured for the OCI IAM Domain Integrated Application
|
||||
client_secret=client_secret, # This is same as the client secret configured for the OCI IAM Domain Integrated Application
|
||||
required_scopes=["openid", "profile", "email"],
|
||||
redirect_path="/auth/callback",
|
||||
base_url="http://localhost:8000",
|
||||
)
|
||||
|
||||
# NOTE: For production use, replace this with a thread-safe cache implementation
|
||||
# such as threading.Lock-protected dict or a proper caching library
|
||||
_global_token_cache = {} # In memory cache for OCI session token signer
|
||||
|
||||
def get_oci_signer() -> TokenExchangeSigner:
|
||||
|
||||
authntoken = get_access_token()
|
||||
tokenID = authntoken.claims.get("jti")
|
||||
token = authntoken.token
|
||||
|
||||
# Check if the signer exists for the token ID in memory cache
|
||||
cached_signer = _global_token_cache.get(tokenID)
|
||||
logger.debug(f"Global cached signer: {cached_signer}")
|
||||
if cached_signer:
|
||||
logger.debug(f"Using globally cached signer for token ID: {tokenID}")
|
||||
return cached_signer
|
||||
|
||||
# If the signer is not yet created for the token then create new OCI signer object
|
||||
logger.debug(f"Creating new signer for token ID: {tokenID}")
|
||||
signer = TokenExchangeSigner(
|
||||
jwt_or_func=token,
|
||||
oci_domain_id=iam_guid.split(".")[0] if iam_guid else None, # This is same as IAM GUID configured for the OCI IAM Domain
|
||||
client_id=client_id, # This is same as the client ID configured for the OCI IAM Domain Integrated Application
|
||||
client_secret=client_secret, # This is same as the client secret configured for the OCI IAM Domain Integrated Application
|
||||
)
|
||||
logger.debug(f"Signer {signer} created for token ID: {tokenID}")
|
||||
|
||||
#Cache the signer object in memory cache
|
||||
_global_token_cache[tokenID] = signer
|
||||
logger.debug(f"Signer cached for token ID: {tokenID}")
|
||||
|
||||
return signer
|
||||
|
||||
mcp = FastMCP("My Protected Server", auth=auth)
|
||||
```
|
||||
"""
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from key_value.aio.protocols import AsyncKeyValue
|
||||
from pydantic import AnyHttpUrl
|
||||
|
||||
from fastmcp.server.auth.oidc_proxy import OIDCProxy
|
||||
from fastmcp.utilities.auth import parse_scopes
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class OCIProvider(OIDCProxy):
|
||||
"""An OCI IAM Domain provider implementation for FastMCP.
|
||||
|
||||
This provider is a complete OCI integration that's ready to use with
|
||||
just the configuration URL, client ID, client secret, and base URL.
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.plugins.auth.oci.provider import OCIProvider
|
||||
|
||||
import os
|
||||
|
||||
# Load configuration from environment
|
||||
auth = OCIProvider(
|
||||
config_url=os.environ.get("OCI_CONFIG_URL"), # OCI IAM Domain OIDC discovery URL
|
||||
client_id=os.environ.get("OCI_CLIENT_ID"), # Client ID configured for the OCI IAM Domain Integrated Application
|
||||
client_secret=os.environ.get("OCI_CLIENT_SECRET"), # Client secret configured for the OCI IAM Domain Integrated Application
|
||||
base_url="http://localhost:8000",
|
||||
required_scopes=["openid", "profile", "email"],
|
||||
redirect_path="/auth/callback",
|
||||
)
|
||||
|
||||
mcp = FastMCP("My Protected Server", auth=auth)
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
config_url: AnyHttpUrl | str,
|
||||
client_id: str,
|
||||
client_secret: str,
|
||||
base_url: AnyHttpUrl | str,
|
||||
resource_base_url: AnyHttpUrl | str | None = None,
|
||||
audience: str | None = None,
|
||||
issuer_url: AnyHttpUrl | str | None = None,
|
||||
required_scopes: list[str] | None = None,
|
||||
redirect_path: str | None = None,
|
||||
allowed_client_redirect_uris: list[str] | None = None,
|
||||
client_storage: AsyncKeyValue | None = None,
|
||||
jwt_signing_key: str | bytes | None = None,
|
||||
require_authorization_consent: bool | Literal["remember", "external"] = True,
|
||||
consent_csp_policy: str | None = None,
|
||||
forward_resource: bool = True,
|
||||
) -> None:
|
||||
"""Initialize OCI OIDC provider.
|
||||
|
||||
Args:
|
||||
config_url: OCI OIDC Discovery URL
|
||||
client_id: OCI IAM Domain Integrated Application client id
|
||||
client_secret: OCI Integrated Application client secret
|
||||
base_url: Public URL where OIDC endpoints will be accessible (includes any mount path)
|
||||
resource_base_url: Optional public base URL for the protected resource metadata
|
||||
and token audience. Defaults to ``base_url``.
|
||||
audience: OCI API audience (optional)
|
||||
issuer_url: Issuer URL for OCI IAM Domain metadata. This will override issuer URL from the discovery URL.
|
||||
required_scopes: Required OCI scopes (defaults to ["openid"])
|
||||
redirect_path: Redirect path configured in OCI IAM Domain Integrated Application. The default is "/auth/callback".
|
||||
allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients.
|
||||
"""
|
||||
# Parse scopes if provided as string
|
||||
oci_required_scopes = (
|
||||
parse_scopes(required_scopes) if required_scopes is not None else ["openid"]
|
||||
)
|
||||
|
||||
super().__init__(
|
||||
config_url=config_url,
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
audience=audience,
|
||||
base_url=base_url,
|
||||
resource_base_url=resource_base_url,
|
||||
issuer_url=issuer_url,
|
||||
redirect_path=redirect_path,
|
||||
required_scopes=oci_required_scopes,
|
||||
allowed_client_redirect_uris=allowed_client_redirect_uris,
|
||||
client_storage=client_storage,
|
||||
jwt_signing_key=jwt_signing_key,
|
||||
require_authorization_consent=require_authorization_consent,
|
||||
consent_csp_policy=consent_csp_policy,
|
||||
forward_resource=forward_resource,
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"Initialized OCI OAuth provider for client %s with scopes: %s",
|
||||
client_id,
|
||||
oci_required_scopes,
|
||||
)
|
||||
5
src/fastmcp/server/plugins/auth/propelauth/__init__.py
Normal file
5
src/fastmcp/server/plugins/auth/propelauth/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
"""PropelAuth auth plugin."""
|
||||
|
||||
from fastmcp.server.plugins.auth.propelauth.plugin import PropelAuth
|
||||
|
||||
__all__ = ["PropelAuth"]
|
||||
77
src/fastmcp/server/plugins/auth/propelauth/plugin.py
Normal file
77
src/fastmcp/server/plugins/auth/propelauth/plugin.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
"""PropelAuth auth plugin."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
import httpx
|
||||
from pydantic import AnyHttpUrl
|
||||
|
||||
from fastmcp.server.auth import AuthProvider
|
||||
from fastmcp.server.plugins.auth._base import AuthPlugin, RemoteAuthConfig
|
||||
from fastmcp.server.plugins.auth.propelauth.provider import (
|
||||
PropelAuthProvider,
|
||||
PropelAuthTokenIntrospectionOverrides,
|
||||
)
|
||||
from fastmcp.server.plugins.base import PluginMeta
|
||||
|
||||
|
||||
class PropelAuthConfig(RemoteAuthConfig):
|
||||
"""Config model for the PropelAuth auth plugin."""
|
||||
|
||||
auth_url: AnyHttpUrl | str | None = None
|
||||
introspection_client_id: str | None = None
|
||||
introspection_client_secret: str | None = None
|
||||
resource: AnyHttpUrl | str | None = None
|
||||
introspection_timeout_seconds: int | None = None
|
||||
introspection_cache_ttl_seconds: int | None = None
|
||||
introspection_max_cache_size: int | None = None
|
||||
|
||||
|
||||
class PropelAuth(AuthPlugin[PropelAuthConfig]):
|
||||
"""Contribute a `PropelAuthProvider` as the server's auth provider."""
|
||||
|
||||
Config: ClassVar[type[PropelAuthConfig]] = PropelAuthConfig
|
||||
|
||||
meta = PluginMeta(name="propelauth-auth")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: PropelAuthConfig | dict[str, Any] | None = None,
|
||||
*,
|
||||
http_client: httpx.AsyncClient | None = None,
|
||||
) -> None:
|
||||
super().__init__(config)
|
||||
self._http_client = http_client
|
||||
|
||||
def auth(self) -> AuthProvider | None:
|
||||
self._require(
|
||||
"auth_url",
|
||||
"introspection_client_id",
|
||||
"introspection_client_secret",
|
||||
"base_url",
|
||||
)
|
||||
overrides: PropelAuthTokenIntrospectionOverrides = {}
|
||||
if self.config.introspection_timeout_seconds is not None:
|
||||
overrides["timeout_seconds"] = self.config.introspection_timeout_seconds
|
||||
if self.config.introspection_cache_ttl_seconds is not None:
|
||||
overrides["cache_ttl_seconds"] = self.config.introspection_cache_ttl_seconds
|
||||
if self.config.introspection_max_cache_size is not None:
|
||||
overrides["max_cache_size"] = self.config.introspection_max_cache_size
|
||||
if self._http_client is not None:
|
||||
overrides["http_client"] = self._http_client
|
||||
|
||||
return PropelAuthProvider(
|
||||
**self._kwargs(
|
||||
"auth_url",
|
||||
"introspection_client_id",
|
||||
"introspection_client_secret",
|
||||
"base_url",
|
||||
"required_scopes",
|
||||
"scopes_supported",
|
||||
"resource_name",
|
||||
"resource_documentation",
|
||||
"resource",
|
||||
),
|
||||
token_introspection_overrides=overrides or None,
|
||||
)
|
||||
234
src/fastmcp/server/plugins/auth/propelauth/provider.py
Normal file
234
src/fastmcp/server/plugins/auth/propelauth/provider.py
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
"""PropelAuth authentication provider for FastMCP.
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.plugins.auth.propelauth.provider import PropelAuthProvider
|
||||
|
||||
auth = PropelAuthProvider(
|
||||
auth_url="https://auth.yourdomain.com",
|
||||
introspection_client_id="your-client-id",
|
||||
introspection_client_secret="your-client-secret",
|
||||
base_url="https://your-fastmcp-server.com",
|
||||
required_scopes=["read:user_data"],
|
||||
)
|
||||
|
||||
mcp = FastMCP("My App", auth=auth)
|
||||
```
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TypedDict
|
||||
|
||||
import httpx
|
||||
from pydantic import AnyHttpUrl, SecretStr
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.routing import Route
|
||||
|
||||
from fastmcp.server.auth import AccessToken, RemoteAuthProvider
|
||||
from fastmcp.server.auth.providers.introspection import IntrospectionTokenVerifier
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class PropelAuthTokenIntrospectionOverrides(TypedDict, total=False):
|
||||
timeout_seconds: int
|
||||
cache_ttl_seconds: int | None
|
||||
max_cache_size: int | None
|
||||
http_client: httpx.AsyncClient | None
|
||||
|
||||
|
||||
class PropelAuthProvider(RemoteAuthProvider):
|
||||
"""PropelAuth resource server provider using OAuth 2.1 token introspection.
|
||||
|
||||
This provider validates access tokens via PropelAuth's introspection endpoint
|
||||
and forwards authorization server metadata for OAuth discovery.
|
||||
|
||||
Setup:
|
||||
1. Enable MCP authentication in the PropelAuth Dashboard
|
||||
2. Configure scopes on the MCP page
|
||||
3. Select which redirect URIs to enable by picking which clients you support
|
||||
4. Generate introspection credentials (Client ID + Client Secret)
|
||||
|
||||
For detailed setup instructions, see:
|
||||
https://docs.propelauth.com/mcp-authentication/overview
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.plugins.auth.propelauth.provider import PropelAuthProvider
|
||||
|
||||
auth = PropelAuthProvider(
|
||||
auth_url="https://auth.yourdomain.com",
|
||||
introspection_client_id="your-client-id",
|
||||
introspection_client_secret="your-client-secret",
|
||||
base_url="https://your-fastmcp-server.com",
|
||||
required_scopes=["read:user_data"],
|
||||
)
|
||||
|
||||
mcp = FastMCP("My App", auth=auth)
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
auth_url: AnyHttpUrl | str,
|
||||
introspection_client_id: str,
|
||||
introspection_client_secret: str | SecretStr,
|
||||
base_url: AnyHttpUrl | str,
|
||||
required_scopes: list[str] | None = None,
|
||||
scopes_supported: list[str] | None = None,
|
||||
resource_name: str | None = None,
|
||||
resource_documentation: AnyHttpUrl | None = None,
|
||||
resource: AnyHttpUrl | str | None = None,
|
||||
token_introspection_overrides: (
|
||||
PropelAuthTokenIntrospectionOverrides | None
|
||||
) = None,
|
||||
):
|
||||
"""Initialize PropelAuth provider.
|
||||
|
||||
Args:
|
||||
auth_url: Your PropelAuth Auth URL (from the Backend Integration page)
|
||||
introspection_client_id: Introspection Client ID from the PropelAuth Dashboard
|
||||
introspection_client_secret: Introspection Client Secret from the PropelAuth Dashboard
|
||||
base_url: Public URL of this FastMCP server
|
||||
required_scopes: Optional list of scopes that must be present in tokens
|
||||
scopes_supported: Optional list of scopes to advertise in OAuth metadata.
|
||||
If None, uses required_scopes. Use this when the scopes clients should
|
||||
request differ from the scopes enforced on tokens.
|
||||
resource_name: Optional name for the protected resource metadata.
|
||||
resource_documentation: Optional documentation URL for the protected resource.
|
||||
resource: Optional resource URI (RFC 8707) identifying this MCP server.
|
||||
Use this when multiple MCP servers share the same PropelAuth
|
||||
authorization server (e.g. ``resource="https://api.example.com/mcp"``),
|
||||
so only tokens intended for this MCP server are accepted.
|
||||
token_introspection_overrides: Optional overrides for the underlying
|
||||
IntrospectionTokenVerifier (timeout, caching, http_client)
|
||||
"""
|
||||
normalized_auth_url = str(auth_url).rstrip("/")
|
||||
introspection_url = f"{normalized_auth_url}/oauth/2.1/introspect"
|
||||
authorization_server_url = AnyHttpUrl(f"{normalized_auth_url}/oauth/2.1")
|
||||
|
||||
if resource is None:
|
||||
self._resource = None
|
||||
logger.debug(
|
||||
"PropelAuthProvider: no resource configured, audience checking disabled"
|
||||
)
|
||||
else:
|
||||
self._resource = str(resource)
|
||||
|
||||
token_verifier = self._create_token_verifier(
|
||||
introspection_url=introspection_url,
|
||||
client_id=introspection_client_id,
|
||||
client_secret=introspection_client_secret,
|
||||
required_scopes=required_scopes,
|
||||
introspection_overrides=token_introspection_overrides,
|
||||
)
|
||||
|
||||
self._normalized_auth_url = normalized_auth_url
|
||||
super().__init__(
|
||||
token_verifier=token_verifier,
|
||||
authorization_servers=[authorization_server_url],
|
||||
base_url=base_url,
|
||||
scopes_supported=scopes_supported,
|
||||
resource_name=resource_name,
|
||||
resource_documentation=resource_documentation,
|
||||
)
|
||||
|
||||
def get_routes(
|
||||
self,
|
||||
mcp_path: str | None = None,
|
||||
) -> list[Route]:
|
||||
"""Get routes for this provider.
|
||||
|
||||
Includes the standard routes from the RemoteAuthProvider (protected resource metadata routes (RFC 9728)),
|
||||
and creates an authorization server metadata route that forwards to PropelAuth's route
|
||||
|
||||
Args:
|
||||
mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp")
|
||||
This is used to advertise the resource URL in metadata.
|
||||
"""
|
||||
routes = super().get_routes(mcp_path)
|
||||
|
||||
async def oauth_authorization_server_metadata(request):
|
||||
"""Forward PropelAuth OAuth authorization server metadata"""
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
f"{self._normalized_auth_url}/.well-known/oauth-authorization-server/oauth/2.1"
|
||||
)
|
||||
response.raise_for_status()
|
||||
metadata = response.json()
|
||||
return JSONResponse(metadata)
|
||||
except Exception as e:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "server_error",
|
||||
"error_description": f"Failed to fetch PropelAuth metadata: {e}",
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
routes.append(
|
||||
Route(
|
||||
"/.well-known/oauth-authorization-server",
|
||||
endpoint=oauth_authorization_server_metadata,
|
||||
methods=["GET"],
|
||||
)
|
||||
)
|
||||
|
||||
return routes
|
||||
|
||||
async def verify_token(self, token: str) -> AccessToken | None:
|
||||
"""Verify token and check the ``aud`` claim against the configured resource."""
|
||||
result = await super().verify_token(token)
|
||||
if result is None or self._resource is None:
|
||||
return result
|
||||
|
||||
aud = result.claims.get("aud")
|
||||
if aud != self._resource:
|
||||
logger.debug(
|
||||
"PropelAuthProvider: token audience %r does not match resource %s",
|
||||
aud,
|
||||
self._resource,
|
||||
)
|
||||
return None
|
||||
|
||||
return result
|
||||
|
||||
def _create_token_verifier(
|
||||
self,
|
||||
introspection_url: str,
|
||||
client_id: str,
|
||||
client_secret: str | SecretStr,
|
||||
required_scopes: list[str] | None,
|
||||
introspection_overrides: PropelAuthTokenIntrospectionOverrides | None,
|
||||
) -> IntrospectionTokenVerifier:
|
||||
# Being defensive here, check for only the fields we are expecting
|
||||
safe_overrides: PropelAuthTokenIntrospectionOverrides = {}
|
||||
if introspection_overrides is not None:
|
||||
if "timeout_seconds" in introspection_overrides:
|
||||
safe_overrides["timeout_seconds"] = introspection_overrides[
|
||||
"timeout_seconds"
|
||||
]
|
||||
if "cache_ttl_seconds" in introspection_overrides:
|
||||
safe_overrides["cache_ttl_seconds"] = introspection_overrides[
|
||||
"cache_ttl_seconds"
|
||||
]
|
||||
if "max_cache_size" in introspection_overrides:
|
||||
safe_overrides["max_cache_size"] = introspection_overrides[
|
||||
"max_cache_size"
|
||||
]
|
||||
if "http_client" in introspection_overrides:
|
||||
safe_overrides["http_client"] = introspection_overrides["http_client"]
|
||||
|
||||
return IntrospectionTokenVerifier(
|
||||
introspection_url=introspection_url,
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
required_scopes=required_scopes,
|
||||
**safe_overrides,
|
||||
)
|
||||
|
|
@ -1,792 +0,0 @@
|
|||
"""First-party auth plugins.
|
||||
|
||||
These plugins are thin, JSON-configurable wrappers around FastMCP's
|
||||
existing auth providers. Python-only dependencies such as HTTP clients,
|
||||
token verifiers, and client storage stay as constructor arguments.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Generic, Literal, TypeVar
|
||||
|
||||
import httpx
|
||||
from key_value.aio.protocols import AsyncKeyValue
|
||||
from pydantic import AnyHttpUrl, BaseModel, ConfigDict
|
||||
|
||||
from fastmcp.server.auth import AuthProvider, TokenVerifier
|
||||
from fastmcp.server.plugins.base import Plugin, PluginMeta
|
||||
|
||||
ConsentMode = bool | Literal["remember", "external"]
|
||||
Algorithm = Literal["RS256", "ES256"]
|
||||
ConfigT = TypeVar("ConfigT", bound=BaseModel)
|
||||
|
||||
|
||||
class _AuthPlugin(Plugin[ConfigT], Generic[ConfigT]):
|
||||
def _require(self, *fields: str) -> None:
|
||||
missing = [field for field in fields if getattr(self.config, field) is None]
|
||||
if missing:
|
||||
names = ", ".join(f"`{field}`" for field in missing)
|
||||
raise ValueError(f"{type(self).__name__} requires {names}.")
|
||||
|
||||
def _require_one(self, *fields: str) -> None:
|
||||
if not any(getattr(self.config, field) is not None for field in fields):
|
||||
names = " or ".join(f"`{field}`" for field in fields)
|
||||
raise ValueError(f"{type(self).__name__} requires {names}.")
|
||||
|
||||
def _kwargs(self, *fields: str) -> dict[str, Any]:
|
||||
return {
|
||||
field: getattr(self.config, field)
|
||||
for field in fields
|
||||
if getattr(self.config, field) is not None
|
||||
}
|
||||
|
||||
|
||||
class _PluginConfig(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class _OAuthProxyConfig(_PluginConfig):
|
||||
base_url: AnyHttpUrl | str | None = None
|
||||
resource_base_url: AnyHttpUrl | str | None = None
|
||||
issuer_url: AnyHttpUrl | str | None = None
|
||||
redirect_path: str | None = None
|
||||
required_scopes: list[str] | None = None
|
||||
allowed_client_redirect_uris: list[str] | None = None
|
||||
jwt_signing_key: str | None = None
|
||||
require_authorization_consent: ConsentMode = True
|
||||
consent_csp_policy: str | None = None
|
||||
forward_resource: bool = True
|
||||
|
||||
|
||||
class _OAuthProviderConfig(_OAuthProxyConfig):
|
||||
client_id: str | None = None
|
||||
client_secret: str | None = None
|
||||
timeout_seconds: int = 10
|
||||
enable_cimd: bool = True
|
||||
|
||||
|
||||
class _RemoteAuthConfig(_PluginConfig):
|
||||
base_url: AnyHttpUrl | str | None = None
|
||||
required_scopes: list[str] | None = None
|
||||
scopes_supported: list[str] | None = None
|
||||
resource_name: str | None = None
|
||||
resource_documentation: AnyHttpUrl | None = None
|
||||
|
||||
|
||||
class Auth0AuthConfig(_OAuthProxyConfig):
|
||||
"""Config model for the Auth0 auth plugin."""
|
||||
|
||||
config_url: AnyHttpUrl | str | None = None
|
||||
client_id: str | None = None
|
||||
client_secret: str | None = None
|
||||
audience: str | None = None
|
||||
|
||||
|
||||
class Auth0Auth(_AuthPlugin[Auth0AuthConfig]):
|
||||
"""Contribute an `Auth0Provider` as the server's auth provider."""
|
||||
|
||||
meta = PluginMeta(name="auth0-auth")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: Auth0AuthConfig | dict[str, Any] | None = None,
|
||||
*,
|
||||
client_storage: AsyncKeyValue | None = None,
|
||||
) -> None:
|
||||
super().__init__(config)
|
||||
self._client_storage = client_storage
|
||||
|
||||
def auth(self) -> AuthProvider | None:
|
||||
from fastmcp.server.auth.providers.auth0 import Auth0Provider
|
||||
|
||||
self._require(
|
||||
"config_url", "client_id", "client_secret", "audience", "base_url"
|
||||
)
|
||||
return Auth0Provider(
|
||||
**self._kwargs(
|
||||
"config_url",
|
||||
"client_id",
|
||||
"client_secret",
|
||||
"audience",
|
||||
"base_url",
|
||||
"resource_base_url",
|
||||
"issuer_url",
|
||||
"required_scopes",
|
||||
"redirect_path",
|
||||
"allowed_client_redirect_uris",
|
||||
"jwt_signing_key",
|
||||
"require_authorization_consent",
|
||||
"consent_csp_policy",
|
||||
"forward_resource",
|
||||
),
|
||||
client_storage=self._client_storage,
|
||||
)
|
||||
|
||||
|
||||
class AuthKitAuthConfig(_RemoteAuthConfig):
|
||||
"""Config model for the WorkOS AuthKit auth plugin."""
|
||||
|
||||
authkit_domain: AnyHttpUrl | str | None = None
|
||||
resource_base_url: AnyHttpUrl | str | None = None
|
||||
|
||||
|
||||
class AuthKitAuth(_AuthPlugin[AuthKitAuthConfig]):
|
||||
"""Contribute an `AuthKitProvider` as the server's auth provider."""
|
||||
|
||||
meta = PluginMeta(name="authkit-auth")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: AuthKitAuthConfig | dict[str, Any] | None = None,
|
||||
*,
|
||||
token_verifier: TokenVerifier | None = None,
|
||||
) -> None:
|
||||
super().__init__(config)
|
||||
self._token_verifier = token_verifier
|
||||
|
||||
def auth(self) -> AuthProvider | None:
|
||||
from fastmcp.server.auth.providers.workos import AuthKitProvider
|
||||
|
||||
self._require("authkit_domain", "base_url")
|
||||
return AuthKitProvider(
|
||||
**self._kwargs(
|
||||
"authkit_domain",
|
||||
"base_url",
|
||||
"resource_base_url",
|
||||
"required_scopes",
|
||||
"scopes_supported",
|
||||
"resource_name",
|
||||
"resource_documentation",
|
||||
),
|
||||
token_verifier=self._token_verifier,
|
||||
)
|
||||
|
||||
|
||||
class AWSCognitoAuthConfig(_OAuthProxyConfig):
|
||||
"""Config model for the AWS Cognito auth plugin."""
|
||||
|
||||
user_pool_id: str | None = None
|
||||
client_id: str | None = None
|
||||
client_secret: str | None = None
|
||||
aws_region: str = "eu-central-1"
|
||||
redirect_path: str | None = "/auth/callback"
|
||||
|
||||
|
||||
class AWSCognitoAuth(_AuthPlugin[AWSCognitoAuthConfig]):
|
||||
"""Contribute an `AWSCognitoProvider` as the server's auth provider."""
|
||||
|
||||
meta = PluginMeta(name="aws-cognito-auth")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: AWSCognitoAuthConfig | dict[str, Any] | None = None,
|
||||
*,
|
||||
client_storage: AsyncKeyValue | None = None,
|
||||
) -> None:
|
||||
super().__init__(config)
|
||||
self._client_storage = client_storage
|
||||
|
||||
def auth(self) -> AuthProvider | None:
|
||||
from fastmcp.server.auth.providers.aws import AWSCognitoProvider
|
||||
|
||||
self._require("user_pool_id", "client_id", "client_secret", "base_url")
|
||||
return AWSCognitoProvider(
|
||||
**self._kwargs(
|
||||
"user_pool_id",
|
||||
"client_id",
|
||||
"client_secret",
|
||||
"base_url",
|
||||
"resource_base_url",
|
||||
"aws_region",
|
||||
"issuer_url",
|
||||
"redirect_path",
|
||||
"required_scopes",
|
||||
"allowed_client_redirect_uris",
|
||||
"jwt_signing_key",
|
||||
"require_authorization_consent",
|
||||
"consent_csp_policy",
|
||||
"forward_resource",
|
||||
),
|
||||
client_storage=self._client_storage,
|
||||
)
|
||||
|
||||
|
||||
class AzureAuthConfig(_OAuthProviderConfig):
|
||||
"""Config model for the Azure auth plugin."""
|
||||
|
||||
tenant_id: str | None = None
|
||||
required_scopes: list[str] | None = None
|
||||
identifier_uri: str | None = None
|
||||
additional_authorize_scopes: list[str] | None = None
|
||||
base_authority: str = "login.microsoftonline.com"
|
||||
|
||||
|
||||
class AzureAuth(_AuthPlugin[AzureAuthConfig]):
|
||||
"""Contribute an `AzureProvider` as the server's auth provider."""
|
||||
|
||||
meta = PluginMeta(name="azure-auth")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: AzureAuthConfig | dict[str, Any] | None = None,
|
||||
*,
|
||||
client_storage: AsyncKeyValue | None = None,
|
||||
http_client: httpx.AsyncClient | None = None,
|
||||
) -> None:
|
||||
super().__init__(config)
|
||||
self._client_storage = client_storage
|
||||
self._http_client = http_client
|
||||
|
||||
def auth(self) -> AuthProvider | None:
|
||||
from fastmcp.server.auth.providers.azure import AzureProvider
|
||||
|
||||
self._require("client_id", "tenant_id", "required_scopes", "base_url")
|
||||
self._require_one("client_secret", "jwt_signing_key")
|
||||
return AzureProvider(
|
||||
**self._kwargs(
|
||||
"client_id",
|
||||
"client_secret",
|
||||
"tenant_id",
|
||||
"required_scopes",
|
||||
"base_url",
|
||||
"resource_base_url",
|
||||
"identifier_uri",
|
||||
"issuer_url",
|
||||
"redirect_path",
|
||||
"additional_authorize_scopes",
|
||||
"allowed_client_redirect_uris",
|
||||
"jwt_signing_key",
|
||||
"require_authorization_consent",
|
||||
"consent_csp_policy",
|
||||
"forward_resource",
|
||||
"base_authority",
|
||||
"enable_cimd",
|
||||
),
|
||||
client_storage=self._client_storage,
|
||||
http_client=self._http_client,
|
||||
)
|
||||
|
||||
|
||||
class ClerkAuthConfig(_OAuthProviderConfig):
|
||||
"""Config model for the Clerk auth plugin."""
|
||||
|
||||
domain: str | None = None
|
||||
valid_scopes: list[str] | None = None
|
||||
extra_authorize_params: dict[str, str] | None = None
|
||||
|
||||
|
||||
class ClerkAuth(_AuthPlugin[ClerkAuthConfig]):
|
||||
"""Contribute a `ClerkProvider` as the server's auth provider."""
|
||||
|
||||
meta = PluginMeta(name="clerk-auth")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: ClerkAuthConfig | dict[str, Any] | None = None,
|
||||
*,
|
||||
client_storage: AsyncKeyValue | None = None,
|
||||
http_client: httpx.AsyncClient | None = None,
|
||||
) -> None:
|
||||
super().__init__(config)
|
||||
self._client_storage = client_storage
|
||||
self._http_client = http_client
|
||||
|
||||
def auth(self) -> AuthProvider | None:
|
||||
from fastmcp.server.auth.providers.clerk import ClerkProvider
|
||||
|
||||
self._require("domain", "client_id", "base_url")
|
||||
self._require_one("client_secret", "jwt_signing_key")
|
||||
return ClerkProvider(
|
||||
**self._kwargs(
|
||||
"domain",
|
||||
"client_id",
|
||||
"client_secret",
|
||||
"base_url",
|
||||
"resource_base_url",
|
||||
"issuer_url",
|
||||
"redirect_path",
|
||||
"required_scopes",
|
||||
"valid_scopes",
|
||||
"timeout_seconds",
|
||||
"allowed_client_redirect_uris",
|
||||
"jwt_signing_key",
|
||||
"require_authorization_consent",
|
||||
"consent_csp_policy",
|
||||
"forward_resource",
|
||||
"extra_authorize_params",
|
||||
"enable_cimd",
|
||||
),
|
||||
client_storage=self._client_storage,
|
||||
http_client=self._http_client,
|
||||
)
|
||||
|
||||
|
||||
class DescopeAuthConfig(_RemoteAuthConfig):
|
||||
"""Config model for the Descope auth plugin."""
|
||||
|
||||
config_url: AnyHttpUrl | str | None = None
|
||||
project_id: str | None = None
|
||||
descope_base_url: AnyHttpUrl | str | None = None
|
||||
|
||||
|
||||
class DescopeAuth(_AuthPlugin[DescopeAuthConfig]):
|
||||
"""Contribute a `DescopeProvider` as the server's auth provider."""
|
||||
|
||||
meta = PluginMeta(name="descope-auth")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: DescopeAuthConfig | dict[str, Any] | None = None,
|
||||
*,
|
||||
token_verifier: TokenVerifier | None = None,
|
||||
) -> None:
|
||||
super().__init__(config)
|
||||
self._token_verifier = token_verifier
|
||||
|
||||
def auth(self) -> AuthProvider | None:
|
||||
from fastmcp.server.auth.providers.descope import DescopeProvider
|
||||
|
||||
self._require("base_url")
|
||||
if self.config.config_url is None:
|
||||
self._require("project_id", "descope_base_url")
|
||||
return DescopeProvider(
|
||||
**self._kwargs(
|
||||
"base_url",
|
||||
"config_url",
|
||||
"project_id",
|
||||
"descope_base_url",
|
||||
"required_scopes",
|
||||
"scopes_supported",
|
||||
"resource_name",
|
||||
"resource_documentation",
|
||||
),
|
||||
token_verifier=self._token_verifier,
|
||||
)
|
||||
|
||||
|
||||
class DiscordAuthConfig(_OAuthProviderConfig):
|
||||
"""Config model for the Discord auth plugin."""
|
||||
|
||||
|
||||
class DiscordAuth(_AuthPlugin[DiscordAuthConfig]):
|
||||
"""Contribute a `DiscordProvider` as the server's auth provider."""
|
||||
|
||||
meta = PluginMeta(name="discord-auth")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: DiscordAuthConfig | dict[str, Any] | None = None,
|
||||
*,
|
||||
client_storage: AsyncKeyValue | None = None,
|
||||
http_client: httpx.AsyncClient | None = None,
|
||||
) -> None:
|
||||
super().__init__(config)
|
||||
self._client_storage = client_storage
|
||||
self._http_client = http_client
|
||||
|
||||
def auth(self) -> AuthProvider | None:
|
||||
from fastmcp.server.auth.providers.discord import DiscordProvider
|
||||
|
||||
self._require("client_id", "client_secret", "base_url")
|
||||
return DiscordProvider(
|
||||
**self._kwargs(
|
||||
"client_id",
|
||||
"client_secret",
|
||||
"base_url",
|
||||
"resource_base_url",
|
||||
"issuer_url",
|
||||
"redirect_path",
|
||||
"required_scopes",
|
||||
"timeout_seconds",
|
||||
"allowed_client_redirect_uris",
|
||||
"jwt_signing_key",
|
||||
"require_authorization_consent",
|
||||
"consent_csp_policy",
|
||||
"forward_resource",
|
||||
"enable_cimd",
|
||||
),
|
||||
client_storage=self._client_storage,
|
||||
http_client=self._http_client,
|
||||
)
|
||||
|
||||
|
||||
class GitHubAuthConfig(_OAuthProviderConfig):
|
||||
"""Config model for the GitHub auth plugin."""
|
||||
|
||||
cache_ttl_seconds: int | None = None
|
||||
max_cache_size: int | None = None
|
||||
|
||||
|
||||
class GitHubAuth(_AuthPlugin[GitHubAuthConfig]):
|
||||
"""Contribute a `GitHubProvider` as the server's auth provider."""
|
||||
|
||||
meta = PluginMeta(name="github-auth")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: GitHubAuthConfig | dict[str, Any] | None = None,
|
||||
*,
|
||||
client_storage: AsyncKeyValue | None = None,
|
||||
http_client: httpx.AsyncClient | None = None,
|
||||
) -> None:
|
||||
super().__init__(config)
|
||||
self._client_storage = client_storage
|
||||
self._http_client = http_client
|
||||
|
||||
def auth(self) -> AuthProvider | None:
|
||||
from fastmcp.server.auth.providers.github import GitHubProvider
|
||||
|
||||
self._require("client_id", "client_secret", "base_url")
|
||||
return GitHubProvider(
|
||||
**self._kwargs(
|
||||
"client_id",
|
||||
"client_secret",
|
||||
"base_url",
|
||||
"resource_base_url",
|
||||
"issuer_url",
|
||||
"redirect_path",
|
||||
"required_scopes",
|
||||
"timeout_seconds",
|
||||
"cache_ttl_seconds",
|
||||
"max_cache_size",
|
||||
"allowed_client_redirect_uris",
|
||||
"jwt_signing_key",
|
||||
"require_authorization_consent",
|
||||
"consent_csp_policy",
|
||||
"forward_resource",
|
||||
"enable_cimd",
|
||||
),
|
||||
client_storage=self._client_storage,
|
||||
http_client=self._http_client,
|
||||
)
|
||||
|
||||
|
||||
class GoogleAuthConfig(_OAuthProviderConfig):
|
||||
"""Config model for the Google auth plugin."""
|
||||
|
||||
valid_scopes: list[str] | None = None
|
||||
extra_authorize_params: dict[str, str] | None = None
|
||||
|
||||
|
||||
class GoogleAuth(_AuthPlugin[GoogleAuthConfig]):
|
||||
"""Contribute a `GoogleProvider` as the server's auth provider."""
|
||||
|
||||
meta = PluginMeta(name="google-auth")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: GoogleAuthConfig | dict[str, Any] | None = None,
|
||||
*,
|
||||
client_storage: AsyncKeyValue | None = None,
|
||||
http_client: httpx.AsyncClient | None = None,
|
||||
) -> None:
|
||||
super().__init__(config)
|
||||
self._client_storage = client_storage
|
||||
self._http_client = http_client
|
||||
|
||||
def auth(self) -> AuthProvider | None:
|
||||
from fastmcp.server.auth.providers.google import GoogleProvider
|
||||
|
||||
self._require("client_id", "base_url")
|
||||
self._require_one("client_secret", "jwt_signing_key")
|
||||
return GoogleProvider(
|
||||
**self._kwargs(
|
||||
"client_id",
|
||||
"client_secret",
|
||||
"base_url",
|
||||
"resource_base_url",
|
||||
"issuer_url",
|
||||
"redirect_path",
|
||||
"required_scopes",
|
||||
"valid_scopes",
|
||||
"timeout_seconds",
|
||||
"allowed_client_redirect_uris",
|
||||
"jwt_signing_key",
|
||||
"require_authorization_consent",
|
||||
"consent_csp_policy",
|
||||
"forward_resource",
|
||||
"extra_authorize_params",
|
||||
"enable_cimd",
|
||||
),
|
||||
client_storage=self._client_storage,
|
||||
http_client=self._http_client,
|
||||
)
|
||||
|
||||
|
||||
class KeycloakAuthConfig(_PluginConfig):
|
||||
"""Config model for the Keycloak auth plugin."""
|
||||
|
||||
realm_url: AnyHttpUrl | str | None = None
|
||||
base_url: AnyHttpUrl | str | None = None
|
||||
required_scopes: list[str] | str | None = None
|
||||
audience: str | list[str] | None = None
|
||||
|
||||
|
||||
class KeycloakAuth(_AuthPlugin[KeycloakAuthConfig]):
|
||||
"""Contribute a `KeycloakAuthProvider` as the server's auth provider."""
|
||||
|
||||
meta = PluginMeta(name="keycloak-auth")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: KeycloakAuthConfig | dict[str, Any] | None = None,
|
||||
*,
|
||||
token_verifier: TokenVerifier | None = None,
|
||||
) -> None:
|
||||
super().__init__(config)
|
||||
self._token_verifier = token_verifier
|
||||
|
||||
def auth(self) -> AuthProvider | None:
|
||||
from fastmcp.server.auth.providers.keycloak import KeycloakAuthProvider
|
||||
|
||||
self._require("realm_url", "base_url")
|
||||
return KeycloakAuthProvider(
|
||||
**self._kwargs("realm_url", "base_url", "required_scopes", "audience"),
|
||||
token_verifier=self._token_verifier,
|
||||
)
|
||||
|
||||
|
||||
class OCIAuthConfig(_OAuthProxyConfig):
|
||||
"""Config model for the OCI auth plugin."""
|
||||
|
||||
config_url: AnyHttpUrl | str | None = None
|
||||
client_id: str | None = None
|
||||
client_secret: str | None = None
|
||||
audience: str | None = None
|
||||
|
||||
|
||||
class OCIAuth(_AuthPlugin[OCIAuthConfig]):
|
||||
"""Contribute an `OCIProvider` as the server's auth provider."""
|
||||
|
||||
meta = PluginMeta(name="oci-auth")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: OCIAuthConfig | dict[str, Any] | None = None,
|
||||
*,
|
||||
client_storage: AsyncKeyValue | None = None,
|
||||
) -> None:
|
||||
super().__init__(config)
|
||||
self._client_storage = client_storage
|
||||
|
||||
def auth(self) -> AuthProvider | None:
|
||||
from fastmcp.server.auth.providers.oci import OCIProvider
|
||||
|
||||
self._require("config_url", "client_id", "client_secret", "base_url")
|
||||
return OCIProvider(
|
||||
**self._kwargs(
|
||||
"config_url",
|
||||
"client_id",
|
||||
"client_secret",
|
||||
"base_url",
|
||||
"resource_base_url",
|
||||
"audience",
|
||||
"issuer_url",
|
||||
"required_scopes",
|
||||
"redirect_path",
|
||||
"allowed_client_redirect_uris",
|
||||
"jwt_signing_key",
|
||||
"require_authorization_consent",
|
||||
"consent_csp_policy",
|
||||
"forward_resource",
|
||||
),
|
||||
client_storage=self._client_storage,
|
||||
)
|
||||
|
||||
|
||||
class PropelAuthConfig(_RemoteAuthConfig):
|
||||
"""Config model for the PropelAuth auth plugin."""
|
||||
|
||||
auth_url: AnyHttpUrl | str | None = None
|
||||
introspection_client_id: str | None = None
|
||||
introspection_client_secret: str | None = None
|
||||
resource: AnyHttpUrl | str | None = None
|
||||
introspection_timeout_seconds: int | None = None
|
||||
introspection_cache_ttl_seconds: int | None = None
|
||||
introspection_max_cache_size: int | None = None
|
||||
|
||||
|
||||
class PropelAuth(_AuthPlugin[PropelAuthConfig]):
|
||||
"""Contribute a `PropelAuthProvider` as the server's auth provider."""
|
||||
|
||||
meta = PluginMeta(name="propelauth-auth")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: PropelAuthConfig | dict[str, Any] | None = None,
|
||||
*,
|
||||
http_client: httpx.AsyncClient | None = None,
|
||||
) -> None:
|
||||
super().__init__(config)
|
||||
self._http_client = http_client
|
||||
|
||||
def auth(self) -> AuthProvider | None:
|
||||
from fastmcp.server.auth.providers.propelauth import (
|
||||
PropelAuthProvider,
|
||||
PropelAuthTokenIntrospectionOverrides,
|
||||
)
|
||||
|
||||
self._require(
|
||||
"auth_url",
|
||||
"introspection_client_id",
|
||||
"introspection_client_secret",
|
||||
"base_url",
|
||||
)
|
||||
overrides: PropelAuthTokenIntrospectionOverrides = {}
|
||||
if self.config.introspection_timeout_seconds is not None:
|
||||
overrides["timeout_seconds"] = self.config.introspection_timeout_seconds
|
||||
if self.config.introspection_cache_ttl_seconds is not None:
|
||||
overrides["cache_ttl_seconds"] = self.config.introspection_cache_ttl_seconds
|
||||
if self.config.introspection_max_cache_size is not None:
|
||||
overrides["max_cache_size"] = self.config.introspection_max_cache_size
|
||||
if self._http_client is not None:
|
||||
overrides["http_client"] = self._http_client
|
||||
|
||||
return PropelAuthProvider(
|
||||
**self._kwargs(
|
||||
"auth_url",
|
||||
"introspection_client_id",
|
||||
"introspection_client_secret",
|
||||
"base_url",
|
||||
"required_scopes",
|
||||
"scopes_supported",
|
||||
"resource_name",
|
||||
"resource_documentation",
|
||||
"resource",
|
||||
),
|
||||
token_introspection_overrides=overrides or None,
|
||||
)
|
||||
|
||||
|
||||
class ScalekitAuthConfig(_RemoteAuthConfig):
|
||||
"""Config model for the Scalekit auth plugin."""
|
||||
|
||||
environment_url: AnyHttpUrl | str | None = None
|
||||
resource_id: str | None = None
|
||||
mcp_url: AnyHttpUrl | str | None = None
|
||||
client_id: str | None = None
|
||||
|
||||
|
||||
class ScalekitAuth(_AuthPlugin[ScalekitAuthConfig]):
|
||||
"""Contribute a `ScalekitProvider` as the server's auth provider."""
|
||||
|
||||
meta = PluginMeta(name="scalekit-auth")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: ScalekitAuthConfig | dict[str, Any] | None = None,
|
||||
*,
|
||||
token_verifier: TokenVerifier | None = None,
|
||||
) -> None:
|
||||
super().__init__(config)
|
||||
self._token_verifier = token_verifier
|
||||
|
||||
def auth(self) -> AuthProvider | None:
|
||||
from fastmcp.server.auth.providers.scalekit import ScalekitProvider
|
||||
|
||||
self._require("environment_url", "resource_id")
|
||||
self._require_one("base_url", "mcp_url")
|
||||
return ScalekitProvider(
|
||||
**self._kwargs(
|
||||
"environment_url",
|
||||
"resource_id",
|
||||
"base_url",
|
||||
"mcp_url",
|
||||
"client_id",
|
||||
"required_scopes",
|
||||
"scopes_supported",
|
||||
"resource_name",
|
||||
"resource_documentation",
|
||||
),
|
||||
token_verifier=self._token_verifier,
|
||||
)
|
||||
|
||||
|
||||
class SupabaseAuthConfig(_RemoteAuthConfig):
|
||||
"""Config model for the Supabase auth plugin."""
|
||||
|
||||
project_url: AnyHttpUrl | str | None = None
|
||||
auth_route: str = "/auth/v1"
|
||||
algorithm: Algorithm = "ES256"
|
||||
|
||||
|
||||
class SupabaseAuth(_AuthPlugin[SupabaseAuthConfig]):
|
||||
"""Contribute a `SupabaseProvider` as the server's auth provider."""
|
||||
|
||||
meta = PluginMeta(name="supabase-auth")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: SupabaseAuthConfig | dict[str, Any] | None = None,
|
||||
*,
|
||||
token_verifier: TokenVerifier | None = None,
|
||||
) -> None:
|
||||
super().__init__(config)
|
||||
self._token_verifier = token_verifier
|
||||
|
||||
def auth(self) -> AuthProvider | None:
|
||||
from fastmcp.server.auth.providers.supabase import SupabaseProvider
|
||||
|
||||
self._require("project_url", "base_url")
|
||||
return SupabaseProvider(
|
||||
**self._kwargs(
|
||||
"project_url",
|
||||
"base_url",
|
||||
"auth_route",
|
||||
"algorithm",
|
||||
"required_scopes",
|
||||
"scopes_supported",
|
||||
"resource_name",
|
||||
"resource_documentation",
|
||||
),
|
||||
token_verifier=self._token_verifier,
|
||||
)
|
||||
|
||||
|
||||
class WorkOSAuthConfig(_OAuthProviderConfig):
|
||||
"""Config model for the WorkOS auth plugin."""
|
||||
|
||||
authkit_domain: str | None = None
|
||||
|
||||
|
||||
class WorkOSAuth(_AuthPlugin[WorkOSAuthConfig]):
|
||||
"""Contribute a `WorkOSProvider` as the server's auth provider."""
|
||||
|
||||
meta = PluginMeta(name="workos-auth")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: WorkOSAuthConfig | dict[str, Any] | None = None,
|
||||
*,
|
||||
client_storage: AsyncKeyValue | None = None,
|
||||
http_client: httpx.AsyncClient | None = None,
|
||||
) -> None:
|
||||
super().__init__(config)
|
||||
self._client_storage = client_storage
|
||||
self._http_client = http_client
|
||||
|
||||
def auth(self) -> AuthProvider | None:
|
||||
from fastmcp.server.auth.providers.workos import WorkOSProvider
|
||||
|
||||
self._require("client_id", "client_secret", "authkit_domain", "base_url")
|
||||
return WorkOSProvider(
|
||||
**self._kwargs(
|
||||
"client_id",
|
||||
"client_secret",
|
||||
"authkit_domain",
|
||||
"base_url",
|
||||
"resource_base_url",
|
||||
"issuer_url",
|
||||
"redirect_path",
|
||||
"required_scopes",
|
||||
"timeout_seconds",
|
||||
"allowed_client_redirect_uris",
|
||||
"jwt_signing_key",
|
||||
"require_authorization_consent",
|
||||
"consent_csp_policy",
|
||||
"forward_resource",
|
||||
"enable_cimd",
|
||||
),
|
||||
client_storage=self._client_storage,
|
||||
http_client=self._http_client,
|
||||
)
|
||||
5
src/fastmcp/server/plugins/auth/scalekit/__init__.py
Normal file
5
src/fastmcp/server/plugins/auth/scalekit/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
"""Scalekit auth plugin."""
|
||||
|
||||
from fastmcp.server.plugins.auth.scalekit.plugin import ScalekitAuth
|
||||
|
||||
__all__ = ["ScalekitAuth"]
|
||||
56
src/fastmcp/server/plugins/auth/scalekit/plugin.py
Normal file
56
src/fastmcp/server/plugins/auth/scalekit/plugin.py
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
"""Scalekit auth plugin."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from pydantic import AnyHttpUrl
|
||||
|
||||
from fastmcp.server.auth import AuthProvider, TokenVerifier
|
||||
from fastmcp.server.plugins.auth._base import AuthPlugin, RemoteAuthConfig
|
||||
from fastmcp.server.plugins.auth.scalekit.provider import ScalekitProvider
|
||||
from fastmcp.server.plugins.base import PluginMeta
|
||||
|
||||
|
||||
class ScalekitAuthConfig(RemoteAuthConfig):
|
||||
"""Config model for the Scalekit auth plugin."""
|
||||
|
||||
environment_url: AnyHttpUrl | str | None = None
|
||||
resource_id: str | None = None
|
||||
mcp_url: AnyHttpUrl | str | None = None
|
||||
client_id: str | None = None
|
||||
|
||||
|
||||
class ScalekitAuth(AuthPlugin[ScalekitAuthConfig]):
|
||||
"""Contribute a `ScalekitProvider` as the server's auth provider."""
|
||||
|
||||
Config: ClassVar[type[ScalekitAuthConfig]] = ScalekitAuthConfig
|
||||
|
||||
meta = PluginMeta(name="scalekit-auth")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: ScalekitAuthConfig | dict[str, Any] | None = None,
|
||||
*,
|
||||
token_verifier: TokenVerifier | None = None,
|
||||
) -> None:
|
||||
super().__init__(config)
|
||||
self._token_verifier = token_verifier
|
||||
|
||||
def auth(self) -> AuthProvider | None:
|
||||
self._require("environment_url", "resource_id")
|
||||
self._require_one("base_url", "mcp_url")
|
||||
return ScalekitProvider(
|
||||
**self._kwargs(
|
||||
"environment_url",
|
||||
"resource_id",
|
||||
"base_url",
|
||||
"mcp_url",
|
||||
"client_id",
|
||||
"required_scopes",
|
||||
"scopes_supported",
|
||||
"resource_name",
|
||||
"resource_documentation",
|
||||
),
|
||||
token_verifier=self._token_verifier,
|
||||
)
|
||||
212
src/fastmcp/server/plugins/auth/scalekit/provider.py
Normal file
212
src/fastmcp/server/plugins/auth/scalekit/provider.py
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
"""Scalekit authentication provider for FastMCP.
|
||||
|
||||
This module provides ScalekitProvider - a complete authentication solution that integrates
|
||||
with Scalekit's OAuth 2.1 and OpenID Connect services, supporting Resource Server
|
||||
authentication for seamless MCP client authentication.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
from pydantic import AnyHttpUrl
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.routing import Route
|
||||
|
||||
from fastmcp.server.auth import RemoteAuthProvider, TokenVerifier
|
||||
from fastmcp.server.auth.providers.jwt import JWTVerifier
|
||||
from fastmcp.utilities.auth import parse_scopes
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class ScalekitProvider(RemoteAuthProvider):
|
||||
"""Scalekit resource server provider for OAuth 2.1 authentication.
|
||||
|
||||
This provider implements Scalekit integration using resource server pattern.
|
||||
FastMCP acts as a protected resource server that validates access tokens issued
|
||||
by Scalekit's authorization server.
|
||||
|
||||
IMPORTANT SETUP REQUIREMENTS:
|
||||
|
||||
1. Create an MCP Server in Scalekit Dashboard:
|
||||
- Go to your [Scalekit Dashboard](https://app.scalekit.com/)
|
||||
- Navigate to MCP Servers section
|
||||
- Register a new MCP Server with appropriate scopes
|
||||
- Ensure the Resource Identifier matches exactly what you configure as MCP URL
|
||||
- Note the Resource ID
|
||||
|
||||
2. Environment Configuration:
|
||||
- Set SCALEKIT_ENVIRONMENT_URL (e.g., https://your-env.scalekit.com)
|
||||
- Set SCALEKIT_RESOURCE_ID from your created resource
|
||||
- Set BASE_URL to your FastMCP server's public URL
|
||||
|
||||
For detailed setup instructions, see:
|
||||
https://docs.scalekit.com/mcp/overview/
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastmcp.server.plugins.auth.scalekit.provider import ScalekitProvider
|
||||
|
||||
# Create Scalekit resource server provider
|
||||
scalekit_auth = ScalekitProvider(
|
||||
environment_url="https://your-env.scalekit.com",
|
||||
resource_id="sk_resource_...",
|
||||
base_url="https://your-fastmcp-server.com",
|
||||
)
|
||||
|
||||
# Use with FastMCP
|
||||
mcp = FastMCP("My App", auth=scalekit_auth)
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
environment_url: AnyHttpUrl | str,
|
||||
resource_id: str,
|
||||
base_url: AnyHttpUrl | str | None = None,
|
||||
mcp_url: AnyHttpUrl | str | None = None,
|
||||
client_id: str | None = None,
|
||||
required_scopes: list[str] | None = None,
|
||||
scopes_supported: list[str] | None = None,
|
||||
resource_name: str | None = None,
|
||||
resource_documentation: AnyHttpUrl | None = None,
|
||||
token_verifier: TokenVerifier | None = None,
|
||||
):
|
||||
"""Initialize Scalekit resource server provider.
|
||||
|
||||
Args:
|
||||
environment_url: Your Scalekit environment URL (e.g., "https://your-env.scalekit.com")
|
||||
resource_id: Your Scalekit resource ID
|
||||
base_url: Public URL of this FastMCP server (or use mcp_url for backwards compatibility)
|
||||
mcp_url: Deprecated alias for base_url. Will be removed in a future release.
|
||||
client_id: Deprecated parameter, no longer required. Will be removed in a future release.
|
||||
required_scopes: Optional list of scopes that must be present in tokens
|
||||
scopes_supported: Optional list of scopes to advertise in OAuth metadata.
|
||||
If None, uses required_scopes. Use this when the scopes clients should
|
||||
request differ from the scopes enforced on tokens.
|
||||
resource_name: Optional name for the protected resource metadata.
|
||||
resource_documentation: Optional documentation URL for the protected resource.
|
||||
token_verifier: Optional token verifier. If None, creates JWT verifier for Scalekit
|
||||
"""
|
||||
# Resolve base_url from mcp_url if needed (backwards compatibility)
|
||||
resolved_base_url = base_url or mcp_url
|
||||
if not resolved_base_url:
|
||||
raise ValueError("Either base_url or mcp_url must be provided")
|
||||
|
||||
if mcp_url is not None:
|
||||
logger.warning(
|
||||
"ScalekitProvider parameter 'mcp_url' is deprecated and will be removed in a future release. "
|
||||
"Rename it to 'base_url'."
|
||||
)
|
||||
|
||||
if client_id is not None:
|
||||
logger.warning(
|
||||
"ScalekitProvider no longer requires 'client_id'. The parameter is accepted only for backward "
|
||||
"compatibility and will be removed in a future release."
|
||||
)
|
||||
|
||||
self.environment_url = str(environment_url).rstrip("/")
|
||||
self.resource_id = resource_id
|
||||
parsed_scopes = (
|
||||
parse_scopes(required_scopes) if required_scopes is not None else []
|
||||
)
|
||||
self.required_scopes = parsed_scopes
|
||||
base_url_value = str(resolved_base_url)
|
||||
|
||||
logger.debug(
|
||||
"Initializing ScalekitProvider: environment_url=%s resource_id=%s base_url=%s required_scopes=%s",
|
||||
self.environment_url,
|
||||
self.resource_id,
|
||||
base_url_value,
|
||||
self.required_scopes,
|
||||
)
|
||||
|
||||
# Create default JWT verifier if none provided
|
||||
if token_verifier is None:
|
||||
logger.debug(
|
||||
"Creating default JWTVerifier for Scalekit: jwks_uri=%s issuer=%s required_scopes=%s",
|
||||
f"{self.environment_url}/keys",
|
||||
self.environment_url,
|
||||
self.required_scopes,
|
||||
)
|
||||
token_verifier = JWTVerifier(
|
||||
jwks_uri=f"{self.environment_url}/keys",
|
||||
issuer=self.environment_url,
|
||||
algorithm="RS256",
|
||||
audience=self.resource_id,
|
||||
required_scopes=self.required_scopes or None,
|
||||
)
|
||||
else:
|
||||
logger.debug("Using custom token verifier for ScalekitProvider")
|
||||
|
||||
# Initialize RemoteAuthProvider with Scalekit as the authorization server
|
||||
super().__init__(
|
||||
token_verifier=token_verifier,
|
||||
authorization_servers=[
|
||||
AnyHttpUrl(f"{self.environment_url}/resources/{self.resource_id}")
|
||||
],
|
||||
base_url=base_url_value,
|
||||
scopes_supported=scopes_supported,
|
||||
resource_name=resource_name,
|
||||
resource_documentation=resource_documentation,
|
||||
)
|
||||
|
||||
def get_routes(
|
||||
self,
|
||||
mcp_path: str | None = None,
|
||||
) -> list[Route]:
|
||||
"""Get OAuth routes including Scalekit authorization server metadata forwarding.
|
||||
|
||||
This returns the standard protected resource routes plus an authorization server
|
||||
metadata endpoint that forwards Scalekit's OAuth metadata to clients.
|
||||
|
||||
Args:
|
||||
mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp")
|
||||
This is used to advertise the resource URL in metadata.
|
||||
"""
|
||||
# Get the standard protected resource routes from RemoteAuthProvider
|
||||
routes = super().get_routes(mcp_path)
|
||||
logger.debug(
|
||||
"Preparing Scalekit metadata routes: mcp_path=%s resource_id=%s",
|
||||
mcp_path,
|
||||
self.resource_id,
|
||||
)
|
||||
|
||||
async def oauth_authorization_server_metadata(request):
|
||||
"""Forward Scalekit OAuth authorization server metadata with FastMCP customizations."""
|
||||
try:
|
||||
metadata_url = f"{self.environment_url}/.well-known/oauth-authorization-server/resources/{self.resource_id}"
|
||||
logger.debug(
|
||||
"Fetching Scalekit OAuth metadata: metadata_url=%s", metadata_url
|
||||
)
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(metadata_url)
|
||||
response.raise_for_status()
|
||||
metadata = response.json()
|
||||
logger.debug(
|
||||
"Scalekit metadata fetched successfully: metadata_keys=%s",
|
||||
list(metadata.keys()),
|
||||
)
|
||||
return JSONResponse(metadata)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch Scalekit metadata: {e}")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "server_error",
|
||||
"error_description": f"Failed to fetch Scalekit metadata: {e}",
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
# Add Scalekit authorization server metadata forwarding
|
||||
routes.append(
|
||||
Route(
|
||||
"/.well-known/oauth-authorization-server",
|
||||
endpoint=oauth_authorization_server_metadata,
|
||||
methods=["GET"],
|
||||
)
|
||||
)
|
||||
|
||||
return routes
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
"""Supabase auth plugin."""
|
||||
|
||||
from fastmcp.server.plugins.auth.providers import SupabaseAuth, SupabaseAuthConfig
|
||||
|
||||
__all__ = ["SupabaseAuth", "SupabaseAuthConfig"]
|
||||
5
src/fastmcp/server/plugins/auth/supabase/__init__.py
Normal file
5
src/fastmcp/server/plugins/auth/supabase/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
"""Supabase auth plugin."""
|
||||
|
||||
from fastmcp.server.plugins.auth.supabase.plugin import SupabaseAuth
|
||||
|
||||
__all__ = ["SupabaseAuth"]
|
||||
53
src/fastmcp/server/plugins/auth/supabase/plugin.py
Normal file
53
src/fastmcp/server/plugins/auth/supabase/plugin.py
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
"""Supabase auth plugin."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from pydantic import AnyHttpUrl
|
||||
|
||||
from fastmcp.server.auth import AuthProvider, TokenVerifier
|
||||
from fastmcp.server.plugins.auth._base import Algorithm, AuthPlugin, RemoteAuthConfig
|
||||
from fastmcp.server.plugins.auth.supabase.provider import SupabaseProvider
|
||||
from fastmcp.server.plugins.base import PluginMeta
|
||||
|
||||
|
||||
class SupabaseAuthConfig(RemoteAuthConfig):
|
||||
"""Config model for the Supabase auth plugin."""
|
||||
|
||||
project_url: AnyHttpUrl | str | None = None
|
||||
auth_route: str = "/auth/v1"
|
||||
algorithm: Algorithm = "ES256"
|
||||
|
||||
|
||||
class SupabaseAuth(AuthPlugin[SupabaseAuthConfig]):
|
||||
"""Contribute a `SupabaseProvider` as the server's auth provider."""
|
||||
|
||||
Config: ClassVar[type[SupabaseAuthConfig]] = SupabaseAuthConfig
|
||||
|
||||
meta = PluginMeta(name="supabase-auth")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: SupabaseAuthConfig | dict[str, Any] | None = None,
|
||||
*,
|
||||
token_verifier: TokenVerifier | None = None,
|
||||
) -> None:
|
||||
super().__init__(config)
|
||||
self._token_verifier = token_verifier
|
||||
|
||||
def auth(self) -> AuthProvider | None:
|
||||
self._require("project_url", "base_url")
|
||||
return SupabaseProvider(
|
||||
**self._kwargs(
|
||||
"project_url",
|
||||
"base_url",
|
||||
"auth_route",
|
||||
"algorithm",
|
||||
"required_scopes",
|
||||
"scopes_supported",
|
||||
"resource_name",
|
||||
"resource_documentation",
|
||||
),
|
||||
token_verifier=self._token_verifier,
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue