diff --git a/docs/deployment/http.mdx b/docs/deployment/http.mdx index 54132eb0e..8400b39c2 100644 --- a/docs/deployment/http.mdx +++ b/docs/deployment/http.mdx @@ -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 diff --git a/docs/development/v3-notes/v3-features.mdx b/docs/development/v3-notes/v3-features.mdx index 476349a01..8d54a592f 100644 --- a/docs/development/v3-notes/v3-features.mdx +++ b/docs/development/v3-notes/v3-features.mdx @@ -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( diff --git a/docs/getting-started/upgrading/from-fastmcp-2.mdx b/docs/getting-started/upgrading/from-fastmcp-2.mdx index 1e659e76a..4b461aa5d 100644 --- a/docs/getting-started/upgrading/from-fastmcp-2.mdx +++ b/docs/getting-started/upgrading/from-fastmcp-2.mdx @@ -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"], diff --git a/docs/integrations/auth0.mdx b/docs/integrations/auth0.mdx index 65f9d3873..bf66feee5 100644 --- a/docs/integrations/auth0.mdx +++ b/docs/integrations/auth0.mdx @@ -47,7 +47,7 @@ Create an Application in your Auth0 settings to get the credentials needed for a - 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. @@ -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]) ``` diff --git a/docs/integrations/authkit.mdx b/docs/integrations/authkit.mdx index c77175201..c530779ff 100644 --- a/docs/integrations/authkit.mdx +++ b/docs/integrations/authkit.mdx @@ -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]) ``` diff --git a/docs/integrations/aws-cognito.mdx b/docs/integrations/aws-cognito.mdx index b7df29222..a7be4e16e 100644 --- a/docs/integrations/aws-cognito.mdx +++ b/docs/integrations/aws-cognito.mdx @@ -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]) ``` @@ -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 \ No newline at end of file +- **Compliance**: Meet enterprise security and compliance requirements diff --git a/docs/integrations/azure.mdx b/docs/integrations/azure.mdx index 4376a38ce..95bd159a4 100644 --- a/docs/integrations/azure.mdx +++ b/docs/integrations/azure.mdx @@ -46,7 +46,7 @@ Create an App registration in Azure Portal to get the credentials needed for aut - 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. - **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 - 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`. @@ -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]) ``` @@ -287,7 +289,7 @@ For complete details on these parameters, see the [OAuth Proxy documentation](/s -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 -### 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( diff --git a/docs/integrations/descope.mdx b/docs/integrations/descope.mdx index bfb6cd9c8..304784e1e 100644 --- a/docs/integrations/descope.mdx +++ b/docs/integrations/descope.mdx @@ -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]) ``` diff --git a/docs/integrations/discord.mdx b/docs/integrations/discord.mdx index 5d6c643b7..19d943e11 100644 --- a/docs/integrations/discord.mdx +++ b/docs/integrations/discord.mdx @@ -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]) ``` diff --git a/docs/integrations/github.mdx b/docs/integrations/github.mdx index d493eb1ef..530788b6b 100644 --- a/docs/integrations/github.mdx +++ b/docs/integrations/github.mdx @@ -42,7 +42,7 @@ Create an OAuth App in your GitHub settings to get the credentials needed for au - 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. @@ -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]) ``` diff --git a/docs/integrations/google.mdx b/docs/integrations/google.mdx index 17d49d12f..df0d8152f 100644 --- a/docs/integrations/google.mdx +++ b/docs/integrations/google.mdx @@ -45,7 +45,7 @@ Create an OAuth 2.0 Client ID in your Google Cloud Console to get the credential - 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. @@ -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]) ``` 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). - \ No newline at end of file + diff --git a/docs/integrations/keycloak.mdx b/docs/integrations/keycloak.mdx index 22d61f132..6ec42d3e2 100644 --- a/docs/integrations/keycloak.mdx +++ b/docs/integrations/keycloak.mdx @@ -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, ) ``` diff --git a/docs/integrations/oci.mdx b/docs/integrations/oci.mdx index 02fa36dae..baff973fb 100644 --- a/docs/integrations/oci.mdx +++ b/docs/integrations/oci.mdx @@ -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. @@ -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]) ``` @@ -245,4 +245,4 @@ For complete details on these parameters, see the [OAuth Proxy documentation](/s 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. - \ No newline at end of file + diff --git a/docs/integrations/propelauth.mdx b/docs/integrations/propelauth.mdx index 7f21d2010..8fbafe3d7 100644 --- a/docs/integrations/propelauth.mdx +++ b/docs/integrations/propelauth.mdx @@ -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]) ``` diff --git a/docs/integrations/scalekit.mdx b/docs/integrations/scalekit.mdx index 191b81ca2..0812d3ec0 100644 --- a/docs/integrations/scalekit.mdx +++ b/docs/integrations/scalekit.mdx @@ -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: diff --git a/docs/integrations/supabase.mdx b/docs/integrations/supabase.mdx index 9ffda444d..ffd8b0e5b 100644 --- a/docs/integrations/supabase.mdx +++ b/docs/integrations/supabase.mdx @@ -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]) ``` diff --git a/docs/integrations/workos.mdx b/docs/integrations/workos.mdx index 4f13a5512..406f82e49 100644 --- a/docs/integrations/workos.mdx +++ b/docs/integrations/workos.mdx @@ -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]) ``` @@ -197,4 +199,4 @@ OAuth callback path API request timeout - \ No newline at end of file + diff --git a/docs/servers/auth/authentication.mdx b/docs/servers/auth/authentication.mdx index d37c57f36..b4ce3e270 100644 --- a/docs/servers/auth/authentication.mdx +++ b/docs/servers/auth/authentication.mdx @@ -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( diff --git a/docs/servers/auth/oauth-proxy.mdx b/docs/servers/auth/oauth-proxy.mdx index aff1267ff..3a4f9a00e 100644 --- a/docs/servers/auth/oauth-proxy.mdx +++ b/docs/servers/auth/oauth-proxy.mdx @@ -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( diff --git a/docs/servers/auth/oidc-proxy.mdx b/docs/servers/auth/oidc-proxy.mdx index d33cd611a..138f39bbe 100644 --- a/docs/servers/auth/oidc-proxy.mdx +++ b/docs/servers/auth/oidc-proxy.mdx @@ -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( diff --git a/docs/servers/auth/plugins.mdx b/docs/servers/auth/plugins.mdx index 0bd0f2c52..b5496f59c 100644 --- a/docs/servers/auth/plugins.mdx +++ b/docs/servers/auth/plugins.mdx @@ -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"], diff --git a/docs/servers/auth/remote-oauth.mdx b/docs/servers/auth/remote-oauth.mdx index 7c94a937f..75f90f0c8 100644 --- a/docs/servers/auth/remote-oauth.mdx +++ b/docs/servers/auth/remote-oauth.mdx @@ -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", diff --git a/docs/servers/storage-backends.mdx b/docs/servers/storage-backends.mdx index d13ce176d..b12c6dfb2 100644 --- a/docs/servers/storage-backends.mdx +++ b/docs/servers/storage-backends.mdx @@ -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 diff --git a/docs/v2/deployment/http.mdx b/docs/v2/deployment/http.mdx index fa91c65e0..eb91f378f 100644 --- a/docs/v2/deployment/http.mdx +++ b/docs/v2/deployment/http.mdx @@ -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 diff --git a/docs/v2/integrations/auth0.mdx b/docs/v2/integrations/auth0.mdx index 9e5b186fc..fafd164e4 100644 --- a/docs/v2/integrations/auth0.mdx +++ b/docs/v2/integrations/auth0.mdx @@ -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 -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. @@ -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 diff --git a/docs/v2/integrations/authkit.mdx b/docs/v2/integrations/authkit.mdx index aa7799a35..a87822991 100644 --- a/docs/v2/integrations/authkit.mdx +++ b/docs/v2/integrations/authkit.mdx @@ -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 -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. @@ -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 diff --git a/docs/v2/integrations/aws-cognito.mdx b/docs/v2/integrations/aws-cognito.mdx index 4f39e1953..523d15ff2 100644 --- a/docs/v2/integrations/aws-cognito.mdx +++ b/docs/v2/integrations/aws-cognito.mdx @@ -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 -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. @@ -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 diff --git a/docs/v2/integrations/azure.mdx b/docs/v2/integrations/azure.mdx index 208b3f2ed..a0a33fab8 100644 --- a/docs/v2/integrations/azure.mdx +++ b/docs/v2/integrations/azure.mdx @@ -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 -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. @@ -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 diff --git a/docs/v2/integrations/descope.mdx b/docs/v2/integrations/descope.mdx index 14bade5f4..8193275d6 100644 --- a/docs/v2/integrations/descope.mdx +++ b/docs/v2/integrations/descope.mdx @@ -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 - Set to `fastmcp.server.auth.providers.descope.DescopeProvider` to use + Set to `fastmcp.server.plugins.auth.descope.provider.DescopeProvider` to use Descope authentication. @@ -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 diff --git a/docs/v2/integrations/discord.mdx b/docs/v2/integrations/discord.mdx index b9154e3d6..0e9377d06 100644 --- a/docs/v2/integrations/discord.mdx +++ b/docs/v2/integrations/discord.mdx @@ -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 -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. @@ -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 diff --git a/docs/v2/integrations/github.mdx b/docs/v2/integrations/github.mdx index 5c920063c..3a5cdef11 100644 --- a/docs/v2/integrations/github.mdx +++ b/docs/v2/integrations/github.mdx @@ -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 -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. @@ -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 diff --git a/docs/v2/integrations/google.mdx b/docs/v2/integrations/google.mdx index 3285765e1..853fd1a38 100644 --- a/docs/v2/integrations/google.mdx +++ b/docs/v2/integrations/google.mdx @@ -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 -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. @@ -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 diff --git a/docs/v2/integrations/oci.mdx b/docs/v2/integrations/oci.mdx index 282a51e17..426089a03 100644 --- a/docs/v2/integrations/oci.mdx +++ b/docs/v2/integrations/oci.mdx @@ -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 -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. @@ -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..... diff --git a/docs/v2/integrations/scalekit.mdx b/docs/v2/integrations/scalekit.mdx index abe41a2ba..bb4470ea4 100644 --- a/docs/v2/integrations/scalekit.mdx +++ b/docs/v2/integrations/scalekit.mdx @@ -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 -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. @@ -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 diff --git a/docs/v2/integrations/supabase.mdx b/docs/v2/integrations/supabase.mdx index 94a57e0a2..8a4875df7 100644 --- a/docs/v2/integrations/supabase.mdx +++ b/docs/v2/integrations/supabase.mdx @@ -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 -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. @@ -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 diff --git a/docs/v2/integrations/workos.mdx b/docs/v2/integrations/workos.mdx index b8c01a1d6..0f317dcfe 100644 --- a/docs/v2/integrations/workos.mdx +++ b/docs/v2/integrations/workos.mdx @@ -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 -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. @@ -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 diff --git a/docs/v2/servers/auth/authentication.mdx b/docs/v2/servers/auth/authentication.mdx index c6b829bfe..8821760c5 100644 --- a/docs/v2/servers/auth/authentication.mdx +++ b/docs/v2/servers/auth/authentication.mdx @@ -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 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 @@ -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-..." ``` diff --git a/docs/v2/servers/auth/oauth-proxy.mdx b/docs/v2/servers/auth/oauth-proxy.mdx index eef3bce1c..f82090a32 100644 --- a/docs/v2/servers/auth/oauth-proxy.mdx +++ b/docs/v2/servers/auth/oauth-proxy.mdx @@ -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..." diff --git a/docs/v2/servers/auth/oidc-proxy.mdx b/docs/v2/servers/auth/oidc-proxy.mdx index 750298298..33f37db23 100644 --- a/docs/v2/servers/auth/oidc-proxy.mdx +++ b/docs/v2/servers/auth/oidc-proxy.mdx @@ -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 diff --git a/docs/v2/servers/auth/remote-oauth.mdx b/docs/v2/servers/auth/remote-oauth.mdx index 39c43c559..b1bd1be36 100644 --- a/docs/v2/servers/auth/remote-oauth.mdx +++ b/docs/v2/servers/auth/remote-oauth.mdx @@ -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", diff --git a/docs/v2/servers/storage-backends.mdx b/docs/v2/servers/storage-backends.mdx index 25b8580b0..2cfd08321 100644 --- a/docs/v2/servers/storage-backends.mdx +++ b/docs/v2/servers/storage-backends.mdx @@ -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 diff --git a/src/fastmcp/server/auth/providers/auth0.py b/src/fastmcp/server/auth/providers/auth0.py index 28c50d060..ab716d3bb 100644 --- a/src/fastmcp/server/auth/providers/auth0.py +++ b/src/fastmcp/server/auth/providers/auth0.py @@ -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"] diff --git a/src/fastmcp/server/auth/providers/aws.py b/src/fastmcp/server/auth/providers/aws.py index 6837dc5b5..5a3b63d23 100644 --- a/src/fastmcp/server/auth/providers/aws.py +++ b/src/fastmcp/server/auth/providers/aws.py @@ -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"] diff --git a/src/fastmcp/server/auth/providers/azure.py b/src/fastmcp/server/auth/providers/azure.py index 0f0763b97..a4f1d0dab 100644 --- a/src/fastmcp/server/auth/providers/azure.py +++ b/src/fastmcp/server/auth/providers/azure.py @@ -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"] diff --git a/src/fastmcp/server/auth/providers/clerk.py b/src/fastmcp/server/auth/providers/clerk.py index b17261573..3ab6d5e93 100644 --- a/src/fastmcp/server/auth/providers/clerk.py +++ b/src/fastmcp/server/auth/providers/clerk.py @@ -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://.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"] diff --git a/src/fastmcp/server/auth/providers/descope.py b/src/fastmcp/server/auth/providers/descope.py index 3bdccf8d5..028152dde 100644 --- a/src/fastmcp/server/auth/providers/descope.py +++ b/src/fastmcp/server/auth/providers/descope.py @@ -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"] diff --git a/src/fastmcp/server/auth/providers/discord.py b/src/fastmcp/server/auth/providers/discord.py index edf407ae1..8cba6c8e1 100644 --- a/src/fastmcp/server/auth/providers/discord.py +++ b/src/fastmcp/server/auth/providers/discord.py @@ -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"] diff --git a/src/fastmcp/server/auth/providers/github.py b/src/fastmcp/server/auth/providers/github.py index 57ee16799..928534421 100644 --- a/src/fastmcp/server/auth/providers/github.py +++ b/src/fastmcp/server/auth/providers/github.py @@ -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"] diff --git a/src/fastmcp/server/auth/providers/google.py b/src/fastmcp/server/auth/providers/google.py index 55dcad17c..bf54eec47 100644 --- a/src/fastmcp/server/auth/providers/google.py +++ b/src/fastmcp/server/auth/providers/google.py @@ -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"] diff --git a/src/fastmcp/server/auth/providers/keycloak.py b/src/fastmcp/server/auth/providers/keycloak.py index d018bc4b0..10b5af41f 100644 --- a/src/fastmcp/server/auth/providers/keycloak.py +++ b/src/fastmcp/server/auth/providers/keycloak.py @@ -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"] diff --git a/src/fastmcp/server/auth/providers/oci.py b/src/fastmcp/server/auth/providers/oci.py index ae765299d..4f633a3bb 100644 --- a/src/fastmcp/server/auth/providers/oci.py +++ b/src/fastmcp/server/auth/providers/oci.py @@ -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"] diff --git a/src/fastmcp/server/auth/providers/propelauth.py b/src/fastmcp/server/auth/providers/propelauth.py index 82e55e172..26902841e 100644 --- a/src/fastmcp/server/auth/providers/propelauth.py +++ b/src/fastmcp/server/auth/providers/propelauth.py @@ -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"] diff --git a/src/fastmcp/server/auth/providers/scalekit.py b/src/fastmcp/server/auth/providers/scalekit.py index ffcacc9c5..fd4b5a88f 100644 --- a/src/fastmcp/server/auth/providers/scalekit.py +++ b/src/fastmcp/server/auth/providers/scalekit.py @@ -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"] diff --git a/src/fastmcp/server/auth/providers/supabase.py b/src/fastmcp/server/auth/providers/supabase.py index 527d701ee..08d5a3cd7 100644 --- a/src/fastmcp/server/auth/providers/supabase.py +++ b/src/fastmcp/server/auth/providers/supabase.py @@ -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"] diff --git a/src/fastmcp/server/auth/providers/workos.py b/src/fastmcp/server/auth/providers/workos.py index 91ab83dc1..0c9dcb855 100644 --- a/src/fastmcp/server/auth/providers/workos.py +++ b/src/fastmcp/server/auth/providers/workos.py @@ -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"] diff --git a/src/fastmcp/server/plugins/auth/__init__.py b/src/fastmcp/server/plugins/auth/__init__.py index f78bb69f2..3fa68acda 100644 --- a/src/fastmcp/server/plugins/auth/__init__.py +++ b/src/fastmcp/server/plugins/auth/__init__.py @@ -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] = [] diff --git a/src/fastmcp/server/plugins/auth/_base.py b/src/fastmcp/server/plugins/auth/_base.py new file mode 100644 index 000000000..f5a7f93f5 --- /dev/null +++ b/src/fastmcp/server/plugins/auth/_base.py @@ -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 diff --git a/src/fastmcp/server/plugins/auth/auth0/__init__.py b/src/fastmcp/server/plugins/auth/auth0/__init__.py new file mode 100644 index 000000000..f80363cdb --- /dev/null +++ b/src/fastmcp/server/plugins/auth/auth0/__init__.py @@ -0,0 +1,5 @@ +"""Auth0 auth plugin.""" + +from fastmcp.server.plugins.auth.auth0.plugin import Auth0Auth + +__all__ = ["Auth0Auth"] diff --git a/src/fastmcp/server/plugins/auth/auth0/plugin.py b/src/fastmcp/server/plugins/auth/auth0/plugin.py new file mode 100644 index 000000000..accacf678 --- /dev/null +++ b/src/fastmcp/server/plugins/auth/auth0/plugin.py @@ -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, + ) diff --git a/src/fastmcp/server/plugins/auth/auth0/provider.py b/src/fastmcp/server/plugins/auth/auth0/provider.py new file mode 100644 index 000000000..ab1abe6fa --- /dev/null +++ b/src/fastmcp/server/plugins/auth/auth0/provider.py @@ -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, + ) diff --git a/src/fastmcp/server/plugins/auth/authkit/__init__.py b/src/fastmcp/server/plugins/auth/authkit/__init__.py new file mode 100644 index 000000000..6fa1b75ac --- /dev/null +++ b/src/fastmcp/server/plugins/auth/authkit/__init__.py @@ -0,0 +1,5 @@ +"""WorkOS AuthKit auth plugin.""" + +from fastmcp.server.plugins.auth.authkit.plugin import AuthKitAuth + +__all__ = ["AuthKitAuth"] diff --git a/src/fastmcp/server/plugins/auth/authkit/plugin.py b/src/fastmcp/server/plugins/auth/authkit/plugin.py new file mode 100644 index 000000000..38fc8cb11 --- /dev/null +++ b/src/fastmcp/server/plugins/auth/authkit/plugin.py @@ -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, + ) diff --git a/src/fastmcp/server/plugins/auth/authkit/provider.py b/src/fastmcp/server/plugins/auth/authkit/provider.py new file mode 100644 index 000000000..de6498278 --- /dev/null +++ b/src/fastmcp/server/plugins/auth/authkit/provider.py @@ -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"] diff --git a/src/fastmcp/server/plugins/auth/aws/__init__.py b/src/fastmcp/server/plugins/auth/aws/__init__.py new file mode 100644 index 000000000..d9f6677ff --- /dev/null +++ b/src/fastmcp/server/plugins/auth/aws/__init__.py @@ -0,0 +1,5 @@ +"""AWS Cognito auth plugin.""" + +from fastmcp.server.plugins.auth.aws.plugin import AWSCognitoAuth + +__all__ = ["AWSCognitoAuth"] diff --git a/src/fastmcp/server/plugins/auth/aws/plugin.py b/src/fastmcp/server/plugins/auth/aws/plugin.py new file mode 100644 index 000000000..922c3485a --- /dev/null +++ b/src/fastmcp/server/plugins/auth/aws/plugin.py @@ -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, + ) diff --git a/src/fastmcp/server/plugins/auth/aws/provider.py b/src/fastmcp/server/plugins/auth/aws/provider.py new file mode 100644 index 000000000..6e8dbb74e --- /dev/null +++ b/src/fastmcp/server/plugins/auth/aws/provider.py @@ -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, + ) diff --git a/src/fastmcp/server/plugins/auth/azure/__init__.py b/src/fastmcp/server/plugins/auth/azure/__init__.py new file mode 100644 index 000000000..c15183a2b --- /dev/null +++ b/src/fastmcp/server/plugins/auth/azure/__init__.py @@ -0,0 +1,5 @@ +"""Azure auth plugin.""" + +from fastmcp.server.plugins.auth.azure.plugin import AzureAuth + +__all__ = ["AzureAuth"] diff --git a/src/fastmcp/server/plugins/auth/azure/plugin.py b/src/fastmcp/server/plugins/auth/azure/plugin.py new file mode 100644 index 000000000..96e36274f --- /dev/null +++ b/src/fastmcp/server/plugins/auth/azure/plugin.py @@ -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, + ) diff --git a/src/fastmcp/server/plugins/auth/azure/provider.py b/src/fastmcp/server/plugins/auth/azure/provider.py new file mode 100644 index 000000000..8854665cd --- /dev/null +++ b/src/fastmcp/server/plugins/auth/azure/provider.py @@ -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)) diff --git a/src/fastmcp/server/plugins/auth/clerk/__init__.py b/src/fastmcp/server/plugins/auth/clerk/__init__.py new file mode 100644 index 000000000..22b004aef --- /dev/null +++ b/src/fastmcp/server/plugins/auth/clerk/__init__.py @@ -0,0 +1,5 @@ +"""Clerk auth plugin.""" + +from fastmcp.server.plugins.auth.clerk.plugin import ClerkAuth + +__all__ = ["ClerkAuth"] diff --git a/src/fastmcp/server/plugins/auth/clerk/plugin.py b/src/fastmcp/server/plugins/auth/clerk/plugin.py new file mode 100644 index 000000000..f0ef4c4b1 --- /dev/null +++ b/src/fastmcp/server/plugins/auth/clerk/plugin.py @@ -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, + ) diff --git a/src/fastmcp/server/plugins/auth/clerk/provider.py b/src/fastmcp/server/plugins/auth/clerk/provider.py new file mode 100644 index 000000000..b7888bb47 --- /dev/null +++ b/src/fastmcp/server/plugins/auth/clerk/provider.py @@ -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://.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, + ) diff --git a/src/fastmcp/server/plugins/auth/descope/__init__.py b/src/fastmcp/server/plugins/auth/descope/__init__.py new file mode 100644 index 000000000..14b30571b --- /dev/null +++ b/src/fastmcp/server/plugins/auth/descope/__init__.py @@ -0,0 +1,5 @@ +"""Descope auth plugin.""" + +from fastmcp.server.plugins.auth.descope.plugin import DescopeAuth + +__all__ = ["DescopeAuth"] diff --git a/src/fastmcp/server/plugins/auth/descope/plugin.py b/src/fastmcp/server/plugins/auth/descope/plugin.py new file mode 100644 index 000000000..9b3c9df48 --- /dev/null +++ b/src/fastmcp/server/plugins/auth/descope/plugin.py @@ -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, + ) diff --git a/src/fastmcp/server/plugins/auth/descope/provider.py b/src/fastmcp/server/plugins/auth/descope/provider.py new file mode 100644 index 000000000..64fc7c021 --- /dev/null +++ b/src/fastmcp/server/plugins/auth/descope/provider.py @@ -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 diff --git a/src/fastmcp/server/plugins/auth/discord/__init__.py b/src/fastmcp/server/plugins/auth/discord/__init__.py new file mode 100644 index 000000000..4fea3f539 --- /dev/null +++ b/src/fastmcp/server/plugins/auth/discord/__init__.py @@ -0,0 +1,5 @@ +"""Discord auth plugin.""" + +from fastmcp.server.plugins.auth.discord.plugin import DiscordAuth + +__all__ = ["DiscordAuth"] diff --git a/src/fastmcp/server/plugins/auth/discord/plugin.py b/src/fastmcp/server/plugins/auth/discord/plugin.py new file mode 100644 index 000000000..eedeb78fe --- /dev/null +++ b/src/fastmcp/server/plugins/auth/discord/plugin.py @@ -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, + ) diff --git a/src/fastmcp/server/plugins/auth/discord/provider.py b/src/fastmcp/server/plugins/auth/discord/provider.py new file mode 100644 index 000000000..dc8f72974 --- /dev/null +++ b/src/fastmcp/server/plugins/auth/discord/provider.py @@ -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, + ) diff --git a/src/fastmcp/server/plugins/auth/github/__init__.py b/src/fastmcp/server/plugins/auth/github/__init__.py new file mode 100644 index 000000000..5bc2c1159 --- /dev/null +++ b/src/fastmcp/server/plugins/auth/github/__init__.py @@ -0,0 +1,5 @@ +"""GitHub auth plugin.""" + +from fastmcp.server.plugins.auth.github.plugin import GitHubAuth + +__all__ = ["GitHubAuth"] diff --git a/src/fastmcp/server/plugins/auth/github/plugin.py b/src/fastmcp/server/plugins/auth/github/plugin.py new file mode 100644 index 000000000..e11f4f4e4 --- /dev/null +++ b/src/fastmcp/server/plugins/auth/github/plugin.py @@ -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, + ) diff --git a/src/fastmcp/server/plugins/auth/github/provider.py b/src/fastmcp/server/plugins/auth/github/provider.py new file mode 100644 index 000000000..2197acf72 --- /dev/null +++ b/src/fastmcp/server/plugins/auth/github/provider.py @@ -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, + ) diff --git a/src/fastmcp/server/plugins/auth/google/__init__.py b/src/fastmcp/server/plugins/auth/google/__init__.py new file mode 100644 index 000000000..e2b72a12e --- /dev/null +++ b/src/fastmcp/server/plugins/auth/google/__init__.py @@ -0,0 +1,5 @@ +"""Google auth plugin.""" + +from fastmcp.server.plugins.auth.google.plugin import GoogleAuth + +__all__ = ["GoogleAuth"] diff --git a/src/fastmcp/server/plugins/auth/google/plugin.py b/src/fastmcp/server/plugins/auth/google/plugin.py new file mode 100644 index 000000000..381b82ac7 --- /dev/null +++ b/src/fastmcp/server/plugins/auth/google/plugin.py @@ -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, + ) diff --git a/src/fastmcp/server/plugins/auth/google/provider.py b/src/fastmcp/server/plugins/auth/google/provider.py new file mode 100644 index 000000000..3f165397c --- /dev/null +++ b/src/fastmcp/server/plugins/auth/google/provider.py @@ -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, + ) diff --git a/src/fastmcp/server/plugins/auth/keycloak/__init__.py b/src/fastmcp/server/plugins/auth/keycloak/__init__.py new file mode 100644 index 000000000..e5a5c1593 --- /dev/null +++ b/src/fastmcp/server/plugins/auth/keycloak/__init__.py @@ -0,0 +1,5 @@ +"""Keycloak auth plugin.""" + +from fastmcp.server.plugins.auth.keycloak.plugin import KeycloakAuth + +__all__ = ["KeycloakAuth"] diff --git a/src/fastmcp/server/plugins/auth/keycloak/plugin.py b/src/fastmcp/server/plugins/auth/keycloak/plugin.py new file mode 100644 index 000000000..1399fe082 --- /dev/null +++ b/src/fastmcp/server/plugins/auth/keycloak/plugin.py @@ -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, + ) diff --git a/src/fastmcp/server/plugins/auth/keycloak/provider.py b/src/fastmcp/server/plugins/auth/keycloak/provider.py new file mode 100644 index 000000000..b4c2eead2 --- /dev/null +++ b/src/fastmcp/server/plugins/auth/keycloak/provider.py @@ -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("/")), + ) diff --git a/src/fastmcp/server/plugins/auth/oci/__init__.py b/src/fastmcp/server/plugins/auth/oci/__init__.py new file mode 100644 index 000000000..db49aadf3 --- /dev/null +++ b/src/fastmcp/server/plugins/auth/oci/__init__.py @@ -0,0 +1,5 @@ +"""OCI auth plugin.""" + +from fastmcp.server.plugins.auth.oci.plugin import OCIAuth + +__all__ = ["OCIAuth"] diff --git a/src/fastmcp/server/plugins/auth/oci/plugin.py b/src/fastmcp/server/plugins/auth/oci/plugin.py new file mode 100644 index 000000000..e319e4aac --- /dev/null +++ b/src/fastmcp/server/plugins/auth/oci/plugin.py @@ -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, + ) diff --git a/src/fastmcp/server/plugins/auth/oci/provider.py b/src/fastmcp/server/plugins/auth/oci/provider.py new file mode 100644 index 000000000..fe3841ad6 --- /dev/null +++ b/src/fastmcp/server/plugins/auth/oci/provider.py @@ -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, + ) diff --git a/src/fastmcp/server/plugins/auth/propelauth/__init__.py b/src/fastmcp/server/plugins/auth/propelauth/__init__.py new file mode 100644 index 000000000..dc674d55f --- /dev/null +++ b/src/fastmcp/server/plugins/auth/propelauth/__init__.py @@ -0,0 +1,5 @@ +"""PropelAuth auth plugin.""" + +from fastmcp.server.plugins.auth.propelauth.plugin import PropelAuth + +__all__ = ["PropelAuth"] diff --git a/src/fastmcp/server/plugins/auth/propelauth/plugin.py b/src/fastmcp/server/plugins/auth/propelauth/plugin.py new file mode 100644 index 000000000..f58d14da7 --- /dev/null +++ b/src/fastmcp/server/plugins/auth/propelauth/plugin.py @@ -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, + ) diff --git a/src/fastmcp/server/plugins/auth/propelauth/provider.py b/src/fastmcp/server/plugins/auth/propelauth/provider.py new file mode 100644 index 000000000..b7073b4da --- /dev/null +++ b/src/fastmcp/server/plugins/auth/propelauth/provider.py @@ -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, + ) diff --git a/src/fastmcp/server/plugins/auth/providers.py b/src/fastmcp/server/plugins/auth/providers.py deleted file mode 100644 index d25edcfb4..000000000 --- a/src/fastmcp/server/plugins/auth/providers.py +++ /dev/null @@ -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, - ) diff --git a/src/fastmcp/server/plugins/auth/scalekit/__init__.py b/src/fastmcp/server/plugins/auth/scalekit/__init__.py new file mode 100644 index 000000000..bad3fc3b1 --- /dev/null +++ b/src/fastmcp/server/plugins/auth/scalekit/__init__.py @@ -0,0 +1,5 @@ +"""Scalekit auth plugin.""" + +from fastmcp.server.plugins.auth.scalekit.plugin import ScalekitAuth + +__all__ = ["ScalekitAuth"] diff --git a/src/fastmcp/server/plugins/auth/scalekit/plugin.py b/src/fastmcp/server/plugins/auth/scalekit/plugin.py new file mode 100644 index 000000000..ab4ac2553 --- /dev/null +++ b/src/fastmcp/server/plugins/auth/scalekit/plugin.py @@ -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, + ) diff --git a/src/fastmcp/server/plugins/auth/scalekit/provider.py b/src/fastmcp/server/plugins/auth/scalekit/provider.py new file mode 100644 index 000000000..c64abada4 --- /dev/null +++ b/src/fastmcp/server/plugins/auth/scalekit/provider.py @@ -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 diff --git a/src/fastmcp/server/plugins/auth/supabase.py b/src/fastmcp/server/plugins/auth/supabase.py deleted file mode 100644 index debbcbac0..000000000 --- a/src/fastmcp/server/plugins/auth/supabase.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Supabase auth plugin.""" - -from fastmcp.server.plugins.auth.providers import SupabaseAuth, SupabaseAuthConfig - -__all__ = ["SupabaseAuth", "SupabaseAuthConfig"] diff --git a/src/fastmcp/server/plugins/auth/supabase/__init__.py b/src/fastmcp/server/plugins/auth/supabase/__init__.py new file mode 100644 index 000000000..e2367f17c --- /dev/null +++ b/src/fastmcp/server/plugins/auth/supabase/__init__.py @@ -0,0 +1,5 @@ +"""Supabase auth plugin.""" + +from fastmcp.server.plugins.auth.supabase.plugin import SupabaseAuth + +__all__ = ["SupabaseAuth"] diff --git a/src/fastmcp/server/plugins/auth/supabase/plugin.py b/src/fastmcp/server/plugins/auth/supabase/plugin.py new file mode 100644 index 000000000..8ecf979e9 --- /dev/null +++ b/src/fastmcp/server/plugins/auth/supabase/plugin.py @@ -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, + ) diff --git a/src/fastmcp/server/plugins/auth/supabase/provider.py b/src/fastmcp/server/plugins/auth/supabase/provider.py new file mode 100644 index 000000000..1631df35a --- /dev/null +++ b/src/fastmcp/server/plugins/auth/supabase/provider.py @@ -0,0 +1,181 @@ +"""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. +""" + +from __future__ import annotations + +from typing import Literal + +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 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.plugins.auth.supabase.provider 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 diff --git a/src/fastmcp/server/plugins/auth/workos/__init__.py b/src/fastmcp/server/plugins/auth/workos/__init__.py new file mode 100644 index 000000000..962652a99 --- /dev/null +++ b/src/fastmcp/server/plugins/auth/workos/__init__.py @@ -0,0 +1,5 @@ +"""WorkOS auth plugin.""" + +from fastmcp.server.plugins.auth.workos.plugin import WorkOSAuth + +__all__ = ["WorkOSAuth"] diff --git a/src/fastmcp/server/plugins/auth/workos/plugin.py b/src/fastmcp/server/plugins/auth/workos/plugin.py new file mode 100644 index 000000000..a28cf4526 --- /dev/null +++ b/src/fastmcp/server/plugins/auth/workos/plugin.py @@ -0,0 +1,62 @@ +"""WorkOS 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.workos.provider import WorkOSProvider +from fastmcp.server.plugins.base import PluginMeta + + +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.""" + + Config: ClassVar[type[WorkOSAuthConfig]] = WorkOSAuthConfig + + 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: + 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, + ) diff --git a/src/fastmcp/server/plugins/auth/workos/provider.py b/src/fastmcp/server/plugins/auth/workos/provider.py new file mode 100644 index 000000000..16d94539b --- /dev/null +++ b/src/fastmcp/server/plugins/auth/workos/provider.py @@ -0,0 +1,245 @@ +"""WorkOS OAuth authentication provider for FastMCP.""" + +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 AccessToken, TokenVerifier +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 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.plugins.auth.workos.provider 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, + ) diff --git a/src/fastmcp/server/plugins/base.py b/src/fastmcp/server/plugins/base.py index 8851e22b1..74fc8e63b 100644 --- a/src/fastmcp/server/plugins/base.py +++ b/src/fastmcp/server/plugins/base.py @@ -378,6 +378,9 @@ class Plugin(Generic[C]): def middleware(self): # self.config is typed as PIIRedactorConfig return [PIIMiddleware(self.config.patterns)] + + + plugin = PIIRedactor(PIIRedactor.Config(patterns=["email"])) ``` """ @@ -396,6 +399,17 @@ class Plugin(Generic[C]): for plugins that don't parameterize `Plugin`. """ + Config: ClassVar[type[BaseModel]] = _EmptyConfig + """Public alias for the plugin's config model. + + This lets users instantiate a plugin's config without importing the + implementation-specific config class separately: + + ```python + SomePlugin(SomePlugin.Config(...)) + ``` + """ + config: C """The validated config instance. Typed as `C`, the generic parameter, so `self.config.` type-checks correctly.""" @@ -419,6 +433,7 @@ class Plugin(Generic[C]): config_cls = _resolve_plugin_config_cls(cls) if config_cls is not None: cls._config_cls = config_cls + cls.Config = cls._config_cls # Enforce the JSON-serializable contract on the resolved config. # Every plugin config must round-trip through JSON so plugins # can be loaded from config files, rendered by registry/Horizon diff --git a/src/fastmcp/server/plugins/code_mode/plugin.py b/src/fastmcp/server/plugins/code_mode/plugin.py index a557aee7d..72142d6ba 100644 --- a/src/fastmcp/server/plugins/code_mode/plugin.py +++ b/src/fastmcp/server/plugins/code_mode/plugin.py @@ -10,7 +10,7 @@ for servers with many tools. from __future__ import annotations -from typing import Any, Literal +from typing import Any, ClassVar, Literal from pydantic import BaseModel, ConfigDict @@ -73,7 +73,6 @@ class CodeMode(Plugin[CodeModeConfig]): ```python from fastmcp.server.plugins.code_mode import ( CodeMode, - CodeModeConfig, GetSchemas, ListTools, ) @@ -82,7 +81,7 @@ class CodeMode(Plugin[CodeModeConfig]): "Server", plugins=[ CodeMode( - CodeModeConfig(execute_tool_name="run"), + CodeMode.Config(execute_tool_name="run"), sandbox_provider=my_custom_sandbox, discovery_tools=[ListTools(), GetSchemas()], ) @@ -91,6 +90,8 @@ class CodeMode(Plugin[CodeModeConfig]): ``` """ + Config: ClassVar[type[CodeModeConfig]] = CodeModeConfig + # `meta` is auto-derived (name="code-mode", version=None) — the right # answer for a bundled first-party plugin. Declare `meta` explicitly # (or use `PluginMeta.from_package(...)`) if published separately. diff --git a/src/fastmcp/server/plugins/openapi/__init__.py b/src/fastmcp/server/plugins/openapi/__init__.py index d9ef7e1ca..aacb93eda 100644 --- a/src/fastmcp/server/plugins/openapi/__init__.py +++ b/src/fastmcp/server/plugins/openapi/__init__.py @@ -1,11 +1,11 @@ """OpenAPI plugin — mount an OpenAPI spec as MCP tools/resources. from fastmcp import FastMCP - from fastmcp.server.plugins.openapi import OpenAPI, OpenAPIConfig + from fastmcp.server.plugins.openapi import OpenAPI mcp = FastMCP( "Petstore", - plugins=[OpenAPI(OpenAPIConfig(spec=petstore_spec))], + plugins=[OpenAPI(OpenAPI.Config(spec=petstore_spec))], ) Typed `RouteMap` + `MCPType` are re-exported for the Python-only diff --git a/src/fastmcp/server/plugins/openapi/plugin.py b/src/fastmcp/server/plugins/openapi/plugin.py index f1ca2227a..da6ccd177 100644 --- a/src/fastmcp/server/plugins/openapi/plugin.py +++ b/src/fastmcp/server/plugins/openapi/plugin.py @@ -14,7 +14,7 @@ from __future__ import annotations import json from pathlib import Path -from typing import Any, Literal +from typing import Any, ClassVar, Literal import httpx from pydantic import BaseModel, ConfigDict @@ -123,21 +123,21 @@ class OpenAPI(Plugin[OpenAPIConfig]): """Mount an OpenAPI spec as an MCP server via a plugin. Everything declarative (spec, base URL, headers, route mappings) - goes in `OpenAPIConfig`. Python-only knobs — custom `httpx.AsyncClient`, + goes in `OpenAPI.Config`. Python-only knobs — custom `httpx.AsyncClient`, route-mapping callables, component customization — go in `__init__` kwargs. Example: ```python from fastmcp import FastMCP - from fastmcp.server.plugins.openapi import OpenAPI, OpenAPIConfig + from fastmcp.server.plugins.openapi import OpenAPI # Declarative (JSON-friendly): mcp = FastMCP( "Petstore", plugins=[ OpenAPI( - OpenAPIConfig( + OpenAPI.Config( spec=petstore_spec, base_url="https://api.example.com", headers={"Authorization": "Bearer ..."}, @@ -152,7 +152,7 @@ class OpenAPI(Plugin[OpenAPIConfig]): "Petstore", plugins=[ OpenAPI( - OpenAPIConfig(spec=petstore_spec), + OpenAPI.Config(spec=petstore_spec), client=custom_client, ) ], @@ -160,6 +160,8 @@ class OpenAPI(Plugin[OpenAPIConfig]): ``` """ + Config: ClassVar[type[OpenAPIConfig]] = OpenAPIConfig + # "OpenAPI" is a single technical term; the auto-kebab would split # it into "open-api", which is uglier than the established spelling. meta = PluginMeta(name="openapi") diff --git a/src/fastmcp/server/plugins/prompts_as_tools/plugin.py b/src/fastmcp/server/plugins/prompts_as_tools/plugin.py index a4b192a4c..a853a5483 100644 --- a/src/fastmcp/server/plugins/prompts_as_tools/plugin.py +++ b/src/fastmcp/server/plugins/prompts_as_tools/plugin.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import ClassVar + from pydantic import BaseModel, ConfigDict from fastmcp.server.plugins.base import Plugin @@ -37,5 +39,7 @@ class PromptsAsTools(Plugin[PromptsAsToolsConfig]): ``` """ + Config: ClassVar[type[PromptsAsToolsConfig]] = PromptsAsToolsConfig + def transforms(self) -> list[Transform]: return [PromptsAsToolsTransform()] diff --git a/src/fastmcp/server/plugins/resources_as_tools/plugin.py b/src/fastmcp/server/plugins/resources_as_tools/plugin.py index c6948b793..add9df90f 100644 --- a/src/fastmcp/server/plugins/resources_as_tools/plugin.py +++ b/src/fastmcp/server/plugins/resources_as_tools/plugin.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import ClassVar + from pydantic import BaseModel, ConfigDict from fastmcp.server.plugins.base import Plugin @@ -39,5 +41,7 @@ class ResourcesAsTools(Plugin[ResourcesAsToolsConfig]): ``` """ + Config: ClassVar[type[ResourcesAsToolsConfig]] = ResourcesAsToolsConfig + def transforms(self) -> list[Transform]: return [ResourcesAsToolsTransform()] diff --git a/src/fastmcp/server/plugins/skills/__init__.py b/src/fastmcp/server/plugins/skills/__init__.py index 518507a31..7cfba05f3 100644 --- a/src/fastmcp/server/plugins/skills/__init__.py +++ b/src/fastmcp/server/plugins/skills/__init__.py @@ -1,9 +1,9 @@ """Skills plugin — expose agent skill folders as MCP resources. from fastmcp import FastMCP - from fastmcp.server.plugins.skills import Skills, SkillsConfig + from fastmcp.server.plugins.skills import Skills - mcp = FastMCP("skills", plugins=[Skills(SkillsConfig(vendor="claude"))]) + mcp = FastMCP("skills", plugins=[Skills(Skills.Config(vendor="claude"))]) The underlying `SkillProvider` and `SkillsDirectoryProvider` classes live on `.skill_provider` and `.directory_provider` submodules for diff --git a/src/fastmcp/server/plugins/skills/plugin.py b/src/fastmcp/server/plugins/skills/plugin.py index 3176d123a..cfbb93c29 100644 --- a/src/fastmcp/server/plugins/skills/plugin.py +++ b/src/fastmcp/server/plugins/skills/plugin.py @@ -3,7 +3,7 @@ from __future__ import annotations from pathlib import Path -from typing import Any, Literal +from typing import Any, ClassVar, Literal from pydantic import BaseModel, ConfigDict @@ -14,7 +14,7 @@ from fastmcp.server.providers import Provider # Vendor-name → list of skill-root paths. Captures the same preset # paths the vendor subclasses (`ClaudeSkillsProvider`, `CursorSkillsProvider`, -# etc.) used to hardcode. The dict lets `Skills(SkillsConfig(vendor="claude"))` +# etc.) used to hardcode. The dict lets `Skills(Skills.Config(vendor="claude"))` # replace seven separate subclass names with one plugin + an enum value. VENDOR_PATHS: dict[str, list[Path]] = { "claude": [Path.home() / ".claude" / "skills"], @@ -93,28 +93,30 @@ class Skills(Plugin[SkillsConfig]): Example: ```python from fastmcp import FastMCP - from fastmcp.server.plugins.skills import Skills, SkillsConfig + from fastmcp.server.plugins.skills import Skills # Vendor preset — the common case: mcp = FastMCP( "skills", - plugins=[Skills(SkillsConfig(vendor="claude"))], + plugins=[Skills(Skills.Config(vendor="claude"))], ) # Custom directory: mcp = FastMCP( "skills", - plugins=[Skills(SkillsConfig(directory="./skills"))], + plugins=[Skills(Skills.Config(directory="./skills"))], ) # Single skill folder: mcp = FastMCP( "skills", - plugins=[Skills(SkillsConfig(path="./skills/pdf-processing"))], + plugins=[Skills(Skills.Config(path="./skills/pdf-processing"))], ) ``` """ + Config: ClassVar[type[SkillsConfig]] = SkillsConfig + def providers(self) -> list[Provider]: return [self._build_provider()] diff --git a/src/fastmcp/server/plugins/tool_search/plugin.py b/src/fastmcp/server/plugins/tool_search/plugin.py index cb1a08c69..936610512 100644 --- a/src/fastmcp/server/plugins/tool_search/plugin.py +++ b/src/fastmcp/server/plugins/tool_search/plugin.py @@ -8,7 +8,7 @@ user code should configure behavior through the plugin. from __future__ import annotations -from typing import Literal +from typing import ClassVar, Literal from pydantic import BaseModel, ConfigDict @@ -51,7 +51,7 @@ class ToolSearch(Plugin[ToolSearchConfig]): Example: ```python from fastmcp import FastMCP - from fastmcp.server.plugins.tool_search import ToolSearch, ToolSearchConfig + from fastmcp.server.plugins.tool_search import ToolSearch # Default config: mcp = FastMCP("Server", plugins=[ToolSearch()]) @@ -59,7 +59,7 @@ class ToolSearch(Plugin[ToolSearchConfig]): # Typed config (IDE completion + static validation): mcp = FastMCP( "Server", - plugins=[ToolSearch(ToolSearchConfig(strategy="regex", always_visible=["help"]))], + plugins=[ToolSearch(ToolSearch.Config(strategy="regex", always_visible=["help"]))], ) # Dict config (useful for loading from JSON/YAML): @@ -67,6 +67,8 @@ class ToolSearch(Plugin[ToolSearchConfig]): ``` """ + Config: ClassVar[type[ToolSearchConfig]] = ToolSearchConfig + # `meta` is intentionally omitted: the auto-derived default # (`name="tool-search"`, `version=None`) is appropriate for a # bundled first-party plugin with no independent release cadence. diff --git a/tests/deprecated/test_auth_provider_imports.py b/tests/deprecated/test_auth_provider_imports.py new file mode 100644 index 000000000..57d41fb32 --- /dev/null +++ b/tests/deprecated/test_auth_provider_imports.py @@ -0,0 +1,137 @@ +"""Test that deprecated auth provider import paths still work.""" + +from __future__ import annotations + +import importlib +import sys +import warnings + +import pytest + +from fastmcp.exceptions import FastMCPDeprecationWarning +from fastmcp.utilities.tests import temporary_settings + +AUTH_PROVIDER_SHIMS = [ + ( + "auth0", + "fastmcp.server.plugins.auth.auth0.provider", + ("Auth0Provider",), + ), + ( + "aws", + "fastmcp.server.plugins.auth.aws.provider", + ("AWSCognitoProvider", "AWSCognitoTokenVerifier"), + ), + ( + "azure", + "fastmcp.server.plugins.auth.azure.provider", + ("AzureJWTVerifier", "AzureProvider", "EntraOBOToken"), + ), + ( + "clerk", + "fastmcp.server.plugins.auth.clerk.provider", + ("ClerkProvider", "ClerkTokenVerifier"), + ), + ( + "descope", + "fastmcp.server.plugins.auth.descope.provider", + ("DescopeProvider",), + ), + ( + "discord", + "fastmcp.server.plugins.auth.discord.provider", + ("DiscordProvider", "DiscordTokenVerifier"), + ), + ( + "github", + "fastmcp.server.plugins.auth.github.provider", + ("GitHubProvider", "GitHubTokenVerifier"), + ), + ( + "google", + "fastmcp.server.plugins.auth.google.provider", + ("GoogleProvider", "GoogleTokenVerifier"), + ), + ( + "keycloak", + "fastmcp.server.plugins.auth.keycloak.provider", + ("KeycloakAuthProvider",), + ), + ( + "oci", + "fastmcp.server.plugins.auth.oci.provider", + ("OCIProvider",), + ), + ( + "propelauth", + "fastmcp.server.plugins.auth.propelauth.provider", + ("PropelAuthProvider", "PropelAuthTokenIntrospectionOverrides"), + ), + ( + "scalekit", + "fastmcp.server.plugins.auth.scalekit.provider", + ("ScalekitProvider",), + ), + ( + "supabase", + "fastmcp.server.plugins.auth.supabase.provider", + ("SupabaseProvider",), + ), + ( + "workos", + "fastmcp.server.plugins.auth.workos.provider", + ("WorkOSProvider", "WorkOSTokenVerifier"), + ), + ( + "workos", + "fastmcp.server.plugins.auth.authkit.provider", + ("AuthKitProvider",), + ), +] + + +@pytest.mark.parametrize( + ("legacy_name", "canonical_module_name", "export_names"), + AUTH_PROVIDER_SHIMS, +) +def test_deprecated_auth_provider_imports_still_work( + legacy_name: str, + canonical_module_name: str, + export_names: tuple[str, ...], +): + legacy_module_name = f"fastmcp.server.auth.providers.{legacy_name}" + canonical_module = importlib.import_module(canonical_module_name) + + sys.modules.pop(legacy_module_name, None) + + with temporary_settings(deprecation_warnings=True): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + legacy_module = importlib.import_module(legacy_module_name) + + fastmcp_warns = [ + w for w in caught if issubclass(w.category, FastMCPDeprecationWarning) + ] + assert any("fastmcp.server.plugins.auth" in str(w.message) for w in fastmcp_warns) + + for export_name in export_names: + assert getattr(legacy_module, export_name) is getattr( + canonical_module, export_name + ) + + +@pytest.mark.parametrize( + "legacy_name", + sorted({legacy_name for legacy_name, _, _ in AUTH_PROVIDER_SHIMS}), +) +def test_deprecated_auth_provider_imports_are_silent_when_disabled( + legacy_name: str, +): + legacy_module_name = f"fastmcp.server.auth.providers.{legacy_name}" + + sys.modules.pop(legacy_module_name, None) + + with temporary_settings(deprecation_warnings=False): + with warnings.catch_warnings(): + warnings.simplefilter("error", FastMCPDeprecationWarning) + importlib.import_module(legacy_module_name) diff --git a/tests/integration_tests/auth/test_github_provider_integration.py b/tests/integration_tests/auth/test_github_provider_integration.py index 47d9c944a..8b742155c 100644 --- a/tests/integration_tests/auth/test_github_provider_integration.py +++ b/tests/integration_tests/auth/test_github_provider_integration.py @@ -24,7 +24,7 @@ from fastmcp import FastMCP from fastmcp.client import Client from fastmcp.server.auth.auth import AccessToken from fastmcp.server.auth.oauth_proxy.models import ClientCode -from fastmcp.server.auth.providers.github import GitHubProvider +from fastmcp.server.plugins.auth.github.provider import GitHubProvider from fastmcp.utilities.tests import HeadlessOAuth, run_server_async FASTMCP_TEST_AUTH_GITHUB_CLIENT_ID = os.getenv("FASTMCP_TEST_AUTH_GITHUB_CLIENT_ID") diff --git a/tests/integration_tests/auth/test_keycloak_provider_integration.py b/tests/integration_tests/auth/test_keycloak_provider_integration.py index 3b3a8fafb..3bfca6ab4 100644 --- a/tests/integration_tests/auth/test_keycloak_provider_integration.py +++ b/tests/integration_tests/auth/test_keycloak_provider_integration.py @@ -7,7 +7,7 @@ import httpx import pytest from fastmcp import FastMCP -from fastmcp.server.auth.providers.keycloak import KeycloakAuthProvider +from fastmcp.server.plugins.auth.keycloak.provider import KeycloakAuthProvider TEST_REALM_URL = "https://keycloak.example.com/realms/test" TEST_BASE_URL = "https://fastmcp.example.com" diff --git a/tests/server/auth/providers/test_auth0.py b/tests/server/auth/providers/test_auth0.py index 2c8cb1b46..06220a550 100644 --- a/tests/server/auth/providers/test_auth0.py +++ b/tests/server/auth/providers/test_auth0.py @@ -5,8 +5,8 @@ from unittest.mock import patch import pytest from fastmcp.server.auth.oidc_proxy import OIDCConfiguration -from fastmcp.server.auth.providers.auth0 import Auth0Provider from fastmcp.server.auth.providers.jwt import JWTVerifier +from fastmcp.server.plugins.auth.auth0.provider import Auth0Provider TEST_CONFIG_URL = "https://example.com/.well-known/openid-configuration" TEST_CLIENT_ID = "test-client-id" diff --git a/tests/server/auth/providers/test_aws.py b/tests/server/auth/providers/test_aws.py index 4f5232b53..36764840a 100644 --- a/tests/server/auth/providers/test_aws.py +++ b/tests/server/auth/providers/test_aws.py @@ -3,7 +3,7 @@ from contextlib import contextmanager from unittest.mock import patch -from fastmcp.server.auth.providers.aws import ( +from fastmcp.server.plugins.auth.aws.provider import ( AWSCognitoProvider, ) diff --git a/tests/server/auth/providers/test_azure.py b/tests/server/auth/providers/test_azure.py index 54b119ce9..b5ff43d19 100644 --- a/tests/server/auth/providers/test_azure.py +++ b/tests/server/auth/providers/test_azure.py @@ -8,8 +8,8 @@ from mcp.server.auth.provider import AuthorizationParams from mcp.shared.auth import OAuthClientInformationFull from pydantic import AnyUrl -from fastmcp.server.auth.providers.azure import AzureProvider from fastmcp.server.auth.providers.jwt import JWTVerifier, RSAKeyPair +from fastmcp.server.plugins.auth.azure.provider import AzureProvider @pytest.fixture diff --git a/tests/server/auth/providers/test_azure_scopes.py b/tests/server/auth/providers/test_azure_scopes.py index 8ab35431d..e17c357f5 100644 --- a/tests/server/auth/providers/test_azure_scopes.py +++ b/tests/server/auth/providers/test_azure_scopes.py @@ -4,13 +4,13 @@ import pytest from key_value.aio.stores.memory import MemoryStore from fastmcp.server.auth.auth import MultiAuth -from fastmcp.server.auth.providers.azure import ( +from fastmcp.server.auth.providers.jwt import RSAKeyPair, StaticTokenVerifier +from fastmcp.server.plugins.auth.azure.provider import ( OIDC_SCOPES, AzureJWTVerifier, AzureProvider, _find_azure_provider, ) -from fastmcp.server.auth.providers.jwt import RSAKeyPair, StaticTokenVerifier @pytest.fixture @@ -771,13 +771,16 @@ class TestAzureOBOIntegration: def test_entra_obo_token_is_importable(self): """Test that EntraOBOToken can be imported.""" - from fastmcp.server.auth.providers.azure import EntraOBOToken + from fastmcp.server.plugins.auth.azure.provider import EntraOBOToken assert EntraOBOToken is not None def test_entra_obo_token_creates_dependency(self): """Test that EntraOBOToken creates a dependency with scopes.""" - from fastmcp.server.auth.providers.azure import EntraOBOToken, _EntraOBOToken + from fastmcp.server.plugins.auth.azure.provider import ( + EntraOBOToken, + _EntraOBOToken, + ) dep = EntraOBOToken(["https://graph.microsoft.com/User.Read"]) assert isinstance(dep, _EntraOBOToken) @@ -786,7 +789,7 @@ class TestAzureOBOIntegration: def test_entra_obo_token_is_dependency_instance(self): """Test that EntraOBOToken is a Dependency instance.""" from fastmcp.dependencies import Dependency - from fastmcp.server.auth.providers.azure import _EntraOBOToken + from fastmcp.server.plugins.auth.azure.provider import _EntraOBOToken dep = _EntraOBOToken(["scope"]) assert isinstance(dep, Dependency) diff --git a/tests/server/auth/providers/test_clerk.py b/tests/server/auth/providers/test_clerk.py index 323b36572..3aae0dbfc 100644 --- a/tests/server/auth/providers/test_clerk.py +++ b/tests/server/auth/providers/test_clerk.py @@ -7,7 +7,7 @@ import pytest from key_value.aio.stores.memory import MemoryStore from pytest_httpx import HTTPXMock -from fastmcp.server.auth.providers.clerk import ClerkProvider, ClerkTokenVerifier +from fastmcp.server.plugins.auth.clerk.provider import ClerkProvider, ClerkTokenVerifier CLERK_DOMAIN = "test-instance.clerk.accounts.dev" diff --git a/tests/server/auth/providers/test_descope.py b/tests/server/auth/providers/test_descope.py index 7dcfc477f..2a0a44bb9 100644 --- a/tests/server/auth/providers/test_descope.py +++ b/tests/server/auth/providers/test_descope.py @@ -8,8 +8,8 @@ import pytest from fastmcp import Client, FastMCP from fastmcp.client.transports import StreamableHttpTransport -from fastmcp.server.auth.providers.descope import DescopeProvider from fastmcp.server.auth.providers.jwt import JWTVerifier +from fastmcp.server.plugins.auth.descope.provider import DescopeProvider from fastmcp.utilities.tests import HeadlessOAuth, run_server_async diff --git a/tests/server/auth/providers/test_discord.py b/tests/server/auth/providers/test_discord.py index edf3ffdc7..244b09597 100644 --- a/tests/server/auth/providers/test_discord.py +++ b/tests/server/auth/providers/test_discord.py @@ -5,7 +5,10 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from key_value.aio.stores.memory import MemoryStore -from fastmcp.server.auth.providers.discord import DiscordProvider, DiscordTokenVerifier +from fastmcp.server.plugins.auth.discord.provider import ( + DiscordProvider, + DiscordTokenVerifier, +) @pytest.fixture @@ -120,7 +123,7 @@ class TestDiscordTokenVerifier: mock_client.get.return_value = token_info_response with patch( - "fastmcp.server.auth.providers.discord.httpx.AsyncClient" + "fastmcp.server.plugins.auth.discord.provider.httpx.AsyncClient" ) as mock_client_class: mock_client_class.return_value.__aenter__.return_value = mock_client result = await verifier.verify_token("token") diff --git a/tests/server/auth/providers/test_github.py b/tests/server/auth/providers/test_github.py index a0cc4b9cd..81fed9bf1 100644 --- a/tests/server/auth/providers/test_github.py +++ b/tests/server/auth/providers/test_github.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from key_value.aio.stores.memory import MemoryStore -from fastmcp.server.auth.providers.github import ( +from fastmcp.server.plugins.auth.github.provider import ( GitHubProvider, GitHubTokenVerifier, ) @@ -142,7 +142,7 @@ class TestGitHubTokenVerifier: # Patch the AsyncClient context manager with patch( - "fastmcp.server.auth.providers.github.httpx.AsyncClient" + "fastmcp.server.plugins.auth.github.provider.httpx.AsyncClient" ) as mock_client_class: mock_client_class.return_value.__aenter__.return_value = mock_client @@ -203,7 +203,7 @@ class TestGitHubTokenVerifierCaching: mock_client = AsyncMock() with patch( - "fastmcp.server.auth.providers.github.httpx.AsyncClient" + "fastmcp.server.plugins.auth.github.provider.httpx.AsyncClient" ) as mock_cls: mock_cls.return_value.__aenter__.return_value = mock_client @@ -226,7 +226,7 @@ class TestGitHubTokenVerifierCaching: mock_client = AsyncMock() with patch( - "fastmcp.server.auth.providers.github.httpx.AsyncClient" + "fastmcp.server.plugins.auth.github.provider.httpx.AsyncClient" ) as mock_cls: mock_cls.return_value.__aenter__.return_value = mock_client @@ -244,7 +244,7 @@ class TestGitHubTokenVerifierCaching: mock_client = AsyncMock() with patch( - "fastmcp.server.auth.providers.github.httpx.AsyncClient" + "fastmcp.server.plugins.auth.github.provider.httpx.AsyncClient" ) as mock_cls: mock_cls.return_value.__aenter__.return_value = mock_client @@ -265,7 +265,7 @@ class TestGitHubTokenVerifierCaching: mock_client = AsyncMock() with patch( - "fastmcp.server.auth.providers.github.httpx.AsyncClient" + "fastmcp.server.plugins.auth.github.provider.httpx.AsyncClient" ) as mock_cls: mock_cls.return_value.__aenter__.return_value = mock_client @@ -299,7 +299,7 @@ class TestGitHubTokenVerifierCaching: scopes_response.headers = {} with patch( - "fastmcp.server.auth.providers.github.httpx.AsyncClient" + "fastmcp.server.plugins.auth.github.provider.httpx.AsyncClient" ) as mock_cls: mock_cls.return_value.__aenter__.return_value = mock_client diff --git a/tests/server/auth/providers/test_google.py b/tests/server/auth/providers/test_google.py index 229f04ad3..25d525e02 100644 --- a/tests/server/auth/providers/test_google.py +++ b/tests/server/auth/providers/test_google.py @@ -7,7 +7,7 @@ import pytest from key_value.aio.stores.memory import MemoryStore from pytest_httpx import HTTPXMock -from fastmcp.server.auth.providers.google import ( +from fastmcp.server.plugins.auth.google.provider import ( GOOGLE_SCOPE_ALIASES, GoogleProvider, GoogleTokenVerifier, diff --git a/tests/server/auth/providers/test_http_client.py b/tests/server/auth/providers/test_http_client.py index fa6126183..be9a3bfd7 100644 --- a/tests/server/auth/providers/test_http_client.py +++ b/tests/server/auth/providers/test_http_client.py @@ -212,14 +212,14 @@ class TestGitHubHttpClient: """Test http_client parameter on GitHubTokenVerifier.""" def test_stores_http_client(self): - from fastmcp.server.auth.providers.github import GitHubTokenVerifier + from fastmcp.server.plugins.auth.github.provider import GitHubTokenVerifier client = httpx.AsyncClient() verifier = GitHubTokenVerifier(http_client=client) assert verifier._http_client is client async def test_uses_provided_client(self, httpx_mock: HTTPXMock): - from fastmcp.server.auth.providers.github import GitHubTokenVerifier + from fastmcp.server.plugins.auth.github.provider import GitHubTokenVerifier client = httpx.AsyncClient() httpx_mock.add_response( @@ -242,7 +242,7 @@ class TestDiscordHttpClient: """Test http_client parameter on DiscordTokenVerifier.""" def test_stores_http_client(self): - from fastmcp.server.auth.providers.discord import DiscordTokenVerifier + from fastmcp.server.plugins.auth.discord.provider import DiscordTokenVerifier client = httpx.AsyncClient() verifier = DiscordTokenVerifier( @@ -256,7 +256,7 @@ class TestGoogleHttpClient: """Test http_client parameter on GoogleTokenVerifier.""" def test_stores_http_client(self): - from fastmcp.server.auth.providers.google import GoogleTokenVerifier + from fastmcp.server.plugins.auth.google.provider import GoogleTokenVerifier client = httpx.AsyncClient() verifier = GoogleTokenVerifier(http_client=client) @@ -267,7 +267,7 @@ class TestWorkOSHttpClient: """Test http_client parameter on WorkOSTokenVerifier.""" def test_stores_http_client(self): - from fastmcp.server.auth.providers.workos import WorkOSTokenVerifier + from fastmcp.server.plugins.auth.workos.provider import WorkOSTokenVerifier client = httpx.AsyncClient() verifier = WorkOSTokenVerifier( @@ -281,7 +281,7 @@ class TestProviderHttpClientPassthrough: """Test that convenience providers pass http_client to their verifiers.""" def test_github_provider_threads_http_client(self): - from fastmcp.server.auth.providers.github import ( + from fastmcp.server.plugins.auth.github.provider import ( GitHubProvider, GitHubTokenVerifier, ) @@ -299,7 +299,7 @@ class TestProviderHttpClientPassthrough: assert verifier._http_client is client def test_discord_provider_threads_http_client(self): - from fastmcp.server.auth.providers.discord import ( + from fastmcp.server.plugins.auth.discord.provider import ( DiscordProvider, DiscordTokenVerifier, ) @@ -316,7 +316,7 @@ class TestProviderHttpClientPassthrough: assert verifier._http_client is client def test_google_provider_threads_http_client(self): - from fastmcp.server.auth.providers.google import ( + from fastmcp.server.plugins.auth.google.provider import ( GoogleProvider, GoogleTokenVerifier, ) @@ -333,7 +333,7 @@ class TestProviderHttpClientPassthrough: assert verifier._http_client is client def test_workos_provider_threads_http_client(self): - from fastmcp.server.auth.providers.workos import ( + from fastmcp.server.plugins.auth.workos.provider import ( WorkOSProvider, WorkOSTokenVerifier, ) @@ -351,8 +351,8 @@ class TestProviderHttpClientPassthrough: assert verifier._http_client is client def test_azure_provider_threads_http_client(self): - from fastmcp.server.auth.providers.azure import AzureProvider from fastmcp.server.auth.providers.jwt import JWTVerifier + from fastmcp.server.plugins.auth.azure.provider import AzureProvider client = httpx.AsyncClient() provider = AzureProvider( diff --git a/tests/server/auth/providers/test_keycloak.py b/tests/server/auth/providers/test_keycloak.py index 4312e0103..97c43072e 100644 --- a/tests/server/auth/providers/test_keycloak.py +++ b/tests/server/auth/providers/test_keycloak.py @@ -3,7 +3,7 @@ import pytest from fastmcp.server.auth.providers.jwt import JWTVerifier -from fastmcp.server.auth.providers.keycloak import KeycloakAuthProvider +from fastmcp.server.plugins.auth.keycloak.provider import KeycloakAuthProvider TEST_REALM_URL = "https://keycloak.example.com/realms/test" TEST_BASE_URL = "https://example.com:8000" diff --git a/tests/server/auth/providers/test_propelauth.py b/tests/server/auth/providers/test_propelauth.py index f06efe685..a614aa6ba 100644 --- a/tests/server/auth/providers/test_propelauth.py +++ b/tests/server/auth/providers/test_propelauth.py @@ -10,7 +10,7 @@ from pydantic import SecretStr from fastmcp import Client, FastMCP from fastmcp.server.auth import AccessToken from fastmcp.server.auth.providers.introspection import IntrospectionTokenVerifier -from fastmcp.server.auth.providers.propelauth import ( +from fastmcp.server.plugins.auth.propelauth.provider import ( PropelAuthProvider, PropelAuthTokenIntrospectionOverrides, ) @@ -318,7 +318,7 @@ class TestPropelAuthProviderIntegration: real_httpx_client = httpx.AsyncClient monkeypatch.setattr( - "fastmcp.server.auth.providers.propelauth.httpx.AsyncClient", + "fastmcp.server.plugins.auth.propelauth.provider.httpx.AsyncClient", DummyAsyncClient, ) diff --git a/tests/server/auth/providers/test_scalekit.py b/tests/server/auth/providers/test_scalekit.py index a47840682..47e4dd597 100644 --- a/tests/server/auth/providers/test_scalekit.py +++ b/tests/server/auth/providers/test_scalekit.py @@ -6,7 +6,7 @@ import pytest from fastmcp import Client, FastMCP from fastmcp.client.transports import StreamableHttpTransport from fastmcp.server.auth.providers.jwt import JWTVerifier -from fastmcp.server.auth.providers.scalekit import ScalekitProvider +from fastmcp.server.plugins.auth.scalekit.provider import ScalekitProvider from fastmcp.utilities.tests import HeadlessOAuth, run_server_async @@ -202,7 +202,7 @@ class TestScalekitProviderIntegration: real_httpx_client = httpx.AsyncClient monkeypatch.setattr( - "fastmcp.server.auth.providers.scalekit.httpx.AsyncClient", + "fastmcp.server.plugins.auth.scalekit.provider.httpx.AsyncClient", DummyAsyncClient, ) diff --git a/tests/server/auth/providers/test_supabase.py b/tests/server/auth/providers/test_supabase.py index 1537bff04..8a1a490e5 100644 --- a/tests/server/auth/providers/test_supabase.py +++ b/tests/server/auth/providers/test_supabase.py @@ -8,7 +8,7 @@ import pytest from fastmcp import Client, FastMCP from fastmcp.client.transports import StreamableHttpTransport from fastmcp.server.auth.providers.jwt import JWTVerifier -from fastmcp.server.auth.providers.supabase import SupabaseProvider +from fastmcp.server.plugins.auth.supabase.provider import SupabaseProvider from fastmcp.utilities.tests import HeadlessOAuth, run_server_in_process diff --git a/tests/server/auth/providers/test_workos.py b/tests/server/auth/providers/test_workos.py index 2e87956c1..06c027b08 100644 --- a/tests/server/auth/providers/test_workos.py +++ b/tests/server/auth/providers/test_workos.py @@ -10,8 +10,8 @@ from pytest_httpx import HTTPXMock from fastmcp import Client, FastMCP from fastmcp.client.transports import StreamableHttpTransport from fastmcp.server.auth.providers.jwt import JWTVerifier -from fastmcp.server.auth.providers.workos import ( - AuthKitProvider, +from fastmcp.server.plugins.auth.authkit.provider import AuthKitProvider +from fastmcp.server.plugins.auth.workos.provider import ( WorkOSProvider, WorkOSTokenVerifier, ) diff --git a/tests/server/plugins/test_auth_plugins.py b/tests/server/plugins/test_auth_plugins.py index d208421fa..752febd75 100644 --- a/tests/server/plugins/test_auth_plugins.py +++ b/tests/server/plugins/test_auth_plugins.py @@ -10,53 +10,37 @@ from pydantic import ValidationError from fastmcp import FastMCP from fastmcp.server.auth.oidc_proxy import OIDCConfiguration -from fastmcp.server.auth.providers.auth0 import Auth0Provider -from fastmcp.server.auth.providers.aws import AWSCognitoProvider -from fastmcp.server.auth.providers.azure import AzureProvider -from fastmcp.server.auth.providers.clerk import ClerkProvider -from fastmcp.server.auth.providers.descope import DescopeProvider -from fastmcp.server.auth.providers.discord import DiscordProvider -from fastmcp.server.auth.providers.github import GitHubProvider -from fastmcp.server.auth.providers.google import GoogleProvider from fastmcp.server.auth.providers.jwt import StaticTokenVerifier -from fastmcp.server.auth.providers.keycloak import KeycloakAuthProvider -from fastmcp.server.auth.providers.oci import OCIProvider -from fastmcp.server.auth.providers.propelauth import PropelAuthProvider -from fastmcp.server.auth.providers.scalekit import ScalekitProvider -from fastmcp.server.auth.providers.supabase import SupabaseProvider -from fastmcp.server.auth.providers.workos import AuthKitProvider, WorkOSProvider -from fastmcp.server.plugins.auth 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, -) +from fastmcp.server.plugins.auth.auth0 import Auth0Auth +from fastmcp.server.plugins.auth.auth0.provider import Auth0Provider +from fastmcp.server.plugins.auth.authkit import AuthKitAuth +from fastmcp.server.plugins.auth.authkit.provider import AuthKitProvider +from fastmcp.server.plugins.auth.aws import AWSCognitoAuth +from fastmcp.server.plugins.auth.aws.provider import AWSCognitoProvider +from fastmcp.server.plugins.auth.azure import AzureAuth +from fastmcp.server.plugins.auth.azure.provider import AzureProvider +from fastmcp.server.plugins.auth.clerk import ClerkAuth +from fastmcp.server.plugins.auth.clerk.provider import ClerkProvider +from fastmcp.server.plugins.auth.descope import DescopeAuth +from fastmcp.server.plugins.auth.descope.provider import DescopeProvider +from fastmcp.server.plugins.auth.discord import DiscordAuth +from fastmcp.server.plugins.auth.discord.provider import DiscordProvider +from fastmcp.server.plugins.auth.github import GitHubAuth +from fastmcp.server.plugins.auth.github.provider import GitHubProvider +from fastmcp.server.plugins.auth.google import GoogleAuth +from fastmcp.server.plugins.auth.google.provider import GoogleProvider +from fastmcp.server.plugins.auth.keycloak import KeycloakAuth +from fastmcp.server.plugins.auth.keycloak.provider import KeycloakAuthProvider +from fastmcp.server.plugins.auth.oci import OCIAuth +from fastmcp.server.plugins.auth.oci.provider import OCIProvider +from fastmcp.server.plugins.auth.propelauth import PropelAuth +from fastmcp.server.plugins.auth.propelauth.provider import PropelAuthProvider +from fastmcp.server.plugins.auth.scalekit import ScalekitAuth +from fastmcp.server.plugins.auth.scalekit.provider import ScalekitProvider +from fastmcp.server.plugins.auth.supabase import SupabaseAuth +from fastmcp.server.plugins.auth.supabase.provider import SupabaseProvider +from fastmcp.server.plugins.auth.workos import WorkOSAuth +from fastmcp.server.plugins.auth.workos.provider import WorkOSProvider def _verifier() -> StaticTokenVerifier: @@ -89,7 +73,7 @@ def _mock_oidc_discovery(): PROVIDER_CASES: list[tuple[type, type, dict[str, Any], type]] = [ ( Auth0Auth, - Auth0AuthConfig, + Auth0Auth.Config, { "config_url": "https://idp.example.com/.well-known/openid-configuration", "client_id": "client", @@ -101,7 +85,7 @@ PROVIDER_CASES: list[tuple[type, type, dict[str, Any], type]] = [ ), ( AuthKitAuth, - AuthKitAuthConfig, + AuthKitAuth.Config, { "authkit_domain": "https://example.authkit.app", "base_url": "https://mcp.example.com", @@ -110,7 +94,7 @@ PROVIDER_CASES: list[tuple[type, type, dict[str, Any], type]] = [ ), ( AWSCognitoAuth, - AWSCognitoAuthConfig, + AWSCognitoAuth.Config, { "user_pool_id": "us-east-1_abc", "client_id": "client", @@ -122,7 +106,7 @@ PROVIDER_CASES: list[tuple[type, type, dict[str, Any], type]] = [ ), ( AzureAuth, - AzureAuthConfig, + AzureAuth.Config, { "client_id": "client", "client_secret": "secret", @@ -134,7 +118,7 @@ PROVIDER_CASES: list[tuple[type, type, dict[str, Any], type]] = [ ), ( ClerkAuth, - ClerkAuthConfig, + ClerkAuth.Config, { "domain": "example.clerk.accounts.dev", "client_id": "client", @@ -145,7 +129,7 @@ PROVIDER_CASES: list[tuple[type, type, dict[str, Any], type]] = [ ), ( DescopeAuth, - DescopeAuthConfig, + DescopeAuth.Config, { "config_url": "https://api.descope.com/v1/apps/agentic/P123/M456/.well-known/openid-configuration", "base_url": "https://mcp.example.com", @@ -154,7 +138,7 @@ PROVIDER_CASES: list[tuple[type, type, dict[str, Any], type]] = [ ), ( DiscordAuth, - DiscordAuthConfig, + DiscordAuth.Config, { "client_id": "client", "client_secret": "secret", @@ -164,7 +148,7 @@ PROVIDER_CASES: list[tuple[type, type, dict[str, Any], type]] = [ ), ( GitHubAuth, - GitHubAuthConfig, + GitHubAuth.Config, { "client_id": "client", "client_secret": "secret", @@ -174,7 +158,7 @@ PROVIDER_CASES: list[tuple[type, type, dict[str, Any], type]] = [ ), ( GoogleAuth, - GoogleAuthConfig, + GoogleAuth.Config, { "client_id": "client", "client_secret": "secret", @@ -184,7 +168,7 @@ PROVIDER_CASES: list[tuple[type, type, dict[str, Any], type]] = [ ), ( KeycloakAuth, - KeycloakAuthConfig, + KeycloakAuth.Config, { "realm_url": "https://keycloak.example.com/realms/main", "base_url": "https://mcp.example.com", @@ -193,7 +177,7 @@ PROVIDER_CASES: list[tuple[type, type, dict[str, Any], type]] = [ ), ( OCIAuth, - OCIAuthConfig, + OCIAuth.Config, { "config_url": "https://idp.example.com/.well-known/openid-configuration", "client_id": "client", @@ -204,7 +188,7 @@ PROVIDER_CASES: list[tuple[type, type, dict[str, Any], type]] = [ ), ( PropelAuth, - PropelAuthConfig, + PropelAuth.Config, { "auth_url": "https://auth.example.com", "introspection_client_id": "client", @@ -215,7 +199,7 @@ PROVIDER_CASES: list[tuple[type, type, dict[str, Any], type]] = [ ), ( ScalekitAuth, - ScalekitAuthConfig, + ScalekitAuth.Config, { "environment_url": "https://env.scalekit.com", "resource_id": "res_123", @@ -225,7 +209,7 @@ PROVIDER_CASES: list[tuple[type, type, dict[str, Any], type]] = [ ), ( SupabaseAuth, - SupabaseAuthConfig, + SupabaseAuth.Config, { "project_url": "https://abc123.supabase.co", "base_url": "https://mcp.example.com", @@ -234,7 +218,7 @@ PROVIDER_CASES: list[tuple[type, type, dict[str, Any], type]] = [ ), ( WorkOSAuth, - WorkOSAuthConfig, + WorkOSAuth.Config, { "client_id": "client", "client_secret": "secret", @@ -264,6 +248,7 @@ class TestAuthProviderPlugins: ) def test_config_generic_binding(self, plugin_cls, config_cls, config, provider_cls): assert plugin_cls._config_cls is config_cls + assert plugin_cls.Config is config_cls @pytest.mark.parametrize( ("plugin_cls", "config_cls", "config", "provider_cls"), PROVIDER_CASES @@ -318,7 +303,7 @@ class TestAuthProviderPlugins: def test_supabase_passthroughs_config_and_python_verifier(self): verifier = _verifier() plugin = SupabaseAuth( - SupabaseAuthConfig( + SupabaseAuth.Config( project_url="https://abc123.supabase.co", base_url="https://mcp.example.com", required_scopes=["read"], diff --git a/tests/server/plugins/test_code_mode_plugin.py b/tests/server/plugins/test_code_mode_plugin.py index b06ba869f..5790a676d 100644 --- a/tests/server/plugins/test_code_mode_plugin.py +++ b/tests/server/plugins/test_code_mode_plugin.py @@ -34,6 +34,7 @@ class TestCodeModeConfig: def test_config_generic_binding(self): """`Plugin[CodeModeConfig]` binds CodeModeConfig as the validated config type.""" assert CodeMode._config_cls is CodeModeConfig + assert CodeMode.Config is CodeModeConfig def test_dict_config_accepted(self): """Dict config works for loading from JSON/YAML.""" @@ -42,11 +43,11 @@ class TestCodeModeConfig: def test_unknown_sandbox_rejected(self): with pytest.raises((ValidationError, Exception), match="sandbox"): - CodeModeConfig(sandbox="docker") # ty: ignore[invalid-argument-type] + CodeMode.Config(sandbox="docker") # ty: ignore[invalid-argument-type] def test_unknown_config_key_rejected(self): with pytest.raises((ValidationError, Exception), match="forbid|extra"): - CodeModeConfig(not_a_real_option=True) # ty: ignore[unknown-argument] + CodeMode.Config(not_a_real_option=True) # ty: ignore[unknown-argument] def test_default_meta(self): """CodeMode uses Plugin's auto-derived meta: kebab-cased class diff --git a/tests/server/plugins/test_openapi_plugin.py b/tests/server/plugins/test_openapi_plugin.py index c07c36e3b..c2ab6ff3c 100644 --- a/tests/server/plugins/test_openapi_plugin.py +++ b/tests/server/plugins/test_openapi_plugin.py @@ -56,16 +56,17 @@ PETSTORE_SPEC: dict = { class TestOpenAPIConfig: def test_config_generic_binding(self): assert OpenAPI._config_cls is OpenAPIConfig + assert OpenAPI.Config is OpenAPIConfig def test_default_config_instantiable(self): """Defaults must pass the plugin framework's instantiate-with-no-args contract. The spec/spec_path check fires at providers() time, not at Config construction.""" - assert OpenAPIConfig() # must not raise + assert OpenAPI.Config() # must not raise def test_unknown_config_key_rejected(self): with pytest.raises((ValidationError, Exception), match="forbid|extra"): - OpenAPIConfig(not_a_real_option=True) # ty: ignore[unknown-argument] + OpenAPI.Config(not_a_real_option=True) # ty: ignore[unknown-argument] def test_meta_name_is_single_word(self): """'openapi' is one technical term — explicit meta override @@ -76,7 +77,7 @@ class TestOpenAPIConfig: class TestSpecLoading: async def test_inline_spec_builds_provider(self): - plugin = OpenAPI(OpenAPIConfig(spec=PETSTORE_SPEC)) + plugin = OpenAPI(OpenAPI.Config(spec=PETSTORE_SPEC)) mcp = FastMCP("petstore", plugins=[plugin]) async with Client(mcp) as c: @@ -89,7 +90,7 @@ class TestSpecLoading: spec_file = tmp_path / "petstore.json" spec_file.write_text(json.dumps(PETSTORE_SPEC)) - plugin = OpenAPI(OpenAPIConfig(spec_path=str(spec_file))) + plugin = OpenAPI(OpenAPI.Config(spec_path=str(spec_file))) mcp = FastMCP("petstore", plugins=[plugin]) async with Client(mcp) as c: @@ -113,12 +114,12 @@ class TestSpecLoading: encoding="utf-8", ) - plugin = OpenAPI(OpenAPIConfig(spec_path=str(spec_file))) + plugin = OpenAPI(OpenAPI.Config(spec_path=str(spec_file))) providers = plugin.providers() assert isinstance(providers[0], OpenAPIProvider) def test_missing_spec_fails_at_build_time(self): - plugin = OpenAPI(OpenAPIConfig()) + plugin = OpenAPI(OpenAPI.Config()) with pytest.raises(ValueError, match="spec.*spec_path"): plugin.providers() @@ -126,7 +127,7 @@ class TestSpecLoading: spec_file = tmp_path / "spec.json" spec_file.write_text(json.dumps(PETSTORE_SPEC)) - plugin = OpenAPI(OpenAPIConfig(spec=PETSTORE_SPEC, spec_path=str(spec_file))) + plugin = OpenAPI(OpenAPI.Config(spec=PETSTORE_SPEC, spec_path=str(spec_file))) with pytest.raises(ValueError, match="exactly one"): plugin.providers() @@ -134,7 +135,7 @@ class TestSpecLoading: class TestRouteMapping: def test_route_maps_dict_form_converts_to_typed(self): plugin = OpenAPI( - OpenAPIConfig( + OpenAPI.Config( spec=PETSTORE_SPEC, route_maps=[ RouteMapDict( @@ -149,7 +150,7 @@ class TestRouteMapping: async def test_list_pets_maps_to_resource_via_config(self): plugin = OpenAPI( - OpenAPIConfig( + OpenAPI.Config( spec=PETSTORE_SPEC, route_maps=[ RouteMapDict( @@ -172,7 +173,7 @@ class TestRouteMapping: the dict form in Config — advanced users shouldn't be shadowed by an empty default.""" plugin = OpenAPI( - OpenAPIConfig(spec=PETSTORE_SPEC), + OpenAPI.Config(spec=PETSTORE_SPEC), route_maps=[RouteMap(mcp_type=MCPType.EXCLUDE, pattern=r".*")], ) providers = plugin.providers() @@ -186,7 +187,7 @@ class TestDefaultClient: """When the plugin builds its own httpx client (user didn't pass `client=`), the provider's lifespan must still close it on shutdown. A leaked client was bug noted on PR #4015.""" - plugin = OpenAPI(OpenAPIConfig(spec=PETSTORE_SPEC)) + plugin = OpenAPI(OpenAPI.Config(spec=PETSTORE_SPEC)) provider = plugin.providers()[0] assert isinstance(provider, OpenAPIProvider) client = provider._client @@ -210,7 +211,7 @@ class TestDefaultClient: } ], } - plugin = OpenAPI(OpenAPIConfig(spec=templated_spec)) + plugin = OpenAPI(OpenAPI.Config(spec=templated_spec)) provider = plugin.providers()[0] assert isinstance(provider, OpenAPIProvider) assert str(provider._client.base_url) == "https://us-east.api.example.com" @@ -220,7 +221,7 @@ class TestEscapeHatches: async def test_custom_client_is_used(self): """Passing `client=` bypasses the auto-derived httpx client.""" client = httpx.AsyncClient(base_url="https://override.example.com") - plugin = OpenAPI(OpenAPIConfig(spec=PETSTORE_SPEC), client=client) + plugin = OpenAPI(OpenAPI.Config(spec=PETSTORE_SPEC), client=client) providers = plugin.providers() assert isinstance(providers[0], OpenAPIProvider) # Access the provider's client through the known private attr. diff --git a/tests/server/plugins/test_skills_plugin.py b/tests/server/plugins/test_skills_plugin.py index c3a82b4dd..20d35e8be 100644 --- a/tests/server/plugins/test_skills_plugin.py +++ b/tests/server/plugins/test_skills_plugin.py @@ -24,15 +24,16 @@ from fastmcp.server.plugins.skills.skill_provider import SkillProvider class TestSkillsConfig: def test_config_generic_binding(self): assert Skills._config_cls is SkillsConfig + assert Skills.Config is SkillsConfig def test_default_config_instantiable(self): """Defaults must pass the plugin framework's instantiate-with-no-args contract; the source check fires at providers() time.""" - assert SkillsConfig() # must not raise + assert Skills.Config() # must not raise def test_unknown_config_key_rejected(self): with pytest.raises((ValidationError, Exception), match="forbid|extra"): - SkillsConfig(not_a_real_option=True) # ty: ignore[unknown-argument] + Skills.Config(not_a_real_option=True) # ty: ignore[unknown-argument] def test_default_meta(self): assert Skills.meta.name == "skills" @@ -45,12 +46,12 @@ class TestSourceResolution: skill.mkdir() (skill / "SKILL.md").write_text("# My Skill") - plugin = Skills(SkillsConfig(path=str(skill))) + plugin = Skills(Skills.Config(path=str(skill))) providers = plugin.providers() assert isinstance(providers[0], SkillProvider) def test_directory_source_builds_directory_provider(self, tmp_path: Path): - plugin = Skills(SkillsConfig(directory=str(tmp_path))) + plugin = Skills(Skills.Config(directory=str(tmp_path))) providers = plugin.providers() assert isinstance(providers[0], SkillsDirectoryProvider) @@ -58,7 +59,7 @@ class TestSourceResolution: a, b = tmp_path / "a", tmp_path / "b" a.mkdir() b.mkdir() - plugin = Skills(SkillsConfig(directory=[str(a), str(b)])) + plugin = Skills(Skills.Config(directory=[str(a), str(b)])) providers = plugin.providers() assert isinstance(providers[0], SkillsDirectoryProvider) @@ -66,17 +67,17 @@ class TestSourceResolution: def test_vendor_presets_resolve_to_known_paths(self, vendor: str): """Every vendor string must produce a directory provider rooted at the paths the old vendor subclass used to hardcode.""" - plugin = Skills(SkillsConfig(vendor=cast(Vendor, vendor))) + plugin = Skills(Skills.Config(vendor=cast(Vendor, vendor))) providers = plugin.providers() assert isinstance(providers[0], SkillsDirectoryProvider) def test_no_source_fails_at_build_time(self): - plugin = Skills(SkillsConfig()) + plugin = Skills(Skills.Config()) with pytest.raises(ValueError, match="path.*directory.*vendor"): plugin.providers() def test_multiple_sources_rejected(self, tmp_path: Path): - plugin = Skills(SkillsConfig(directory=str(tmp_path), vendor="claude")) + plugin = Skills(Skills.Config(directory=str(tmp_path), vendor="claude")) with pytest.raises(ValueError, match="exactly one"): plugin.providers() diff --git a/tests/server/plugins/test_tool_search.py b/tests/server/plugins/test_tool_search.py index f2a2990fa..193f7e59e 100644 --- a/tests/server/plugins/test_tool_search.py +++ b/tests/server/plugins/test_tool_search.py @@ -52,20 +52,20 @@ class TestSearchPluginRegistration: assert names == {"search_tools", "call_tool"} async def test_regex_strategy_dispatches_regex_transform(self): - plugin = ToolSearch(ToolSearchConfig(strategy="regex")) + plugin = ToolSearch(ToolSearch.Config(strategy="regex")) transforms = plugin.transforms() assert len(transforms) == 1 assert isinstance(transforms[0], RegexSearchTransform) async def test_bm25_strategy_dispatches_bm25_transform(self): - plugin = ToolSearch(ToolSearchConfig(strategy="bm25")) + plugin = ToolSearch(ToolSearch.Config(strategy="bm25")) transforms = plugin.transforms() assert len(transforms) == 1 assert isinstance(transforms[0], BM25SearchTransform) async def test_always_visible_pins_tools_alongside_search_call(self): mcp = _make_server_with_tools( - [ToolSearch(ToolSearchConfig(always_visible=["add"]))] + [ToolSearch(ToolSearch.Config(always_visible=["add"]))] ) async with Client(mcp) as c: @@ -78,7 +78,7 @@ class TestSearchPluginRegistration: mcp = _make_server_with_tools( [ ToolSearch( - ToolSearchConfig(search_tool_name="find", call_tool_name="invoke") + ToolSearch.Config(search_tool_name="find", call_tool_name="invoke") ) ] ) @@ -100,6 +100,7 @@ class TestSearchPluginRegistration: async def test_search_binds_searchconfig_via_generic_parameter(self): """`Plugin[ToolSearchConfig]` makes ToolSearchConfig the validated config type.""" assert ToolSearch._config_cls is ToolSearchConfig + assert ToolSearch.Config is ToolSearchConfig async def test_dict_config_still_accepted(self): """Dict config path (inherited from Plugin base) constructs cleanly — @@ -119,11 +120,11 @@ class TestSearchPluginRegistration: class TestSearchPluginConfigValidation: def test_unknown_strategy_rejected(self): with pytest.raises((ValidationError, Exception), match="strategy"): - ToolSearchConfig(strategy="fuzzy") # ty: ignore[invalid-argument-type] + ToolSearch.Config(strategy="fuzzy") # ty: ignore[invalid-argument-type] def test_unknown_config_key_rejected(self): with pytest.raises((ValidationError, Exception), match="forbid|extra"): - ToolSearchConfig(not_a_real_option=True) # ty: ignore[unknown-argument] + ToolSearch.Config(not_a_real_option=True) # ty: ignore[unknown-argument] def test_default_meta_name_and_version(self): """ToolSearch relies on Plugin's auto-derived meta: kebab-cased diff --git a/tests/server/test_plugins.py b/tests/server/test_plugins.py index 7ffdb17a9..f53ef4d82 100644 --- a/tests/server/test_plugins.py +++ b/tests/server/test_plugins.py @@ -344,6 +344,7 @@ class TestPluginConstruction: meta = PluginMeta(name="p", version="0.1.0") assert P._config_cls is PConfig + assert P.Config is PConfig def test_unparameterized_plugin_uses_empty_default_config(self): """A Plugin without a generic parameter gets an empty default that