diff --git a/.gitignore b/.gitignore index 7bbea59d0c..0c636d5a03 100644 --- a/.gitignore +++ b/.gitignore @@ -70,6 +70,7 @@ dmypy.json /PLAN.md /TODO.md /STATUS.md +plans/ # Common FastMCP test files /test.py diff --git a/docs/integrations/authkit.mdx b/docs/integrations/authkit.mdx index d16a4db3b4..e99f783516 100644 --- a/docs/integrations/authkit.mdx +++ b/docs/integrations/authkit.mdx @@ -11,6 +11,9 @@ import { VersionBadge } from "/snippets/version-badge.mdx" This guide shows you how to secure your FastMCP server using WorkOS's **AuthKit**, a complete authentication and user management solution. This integration uses the [**Remote OAuth**](/servers/auth/remote-oauth) pattern, where AuthKit handles user login and your FastMCP server validates the tokens. + +AuthKit does not currently support [RFC 8707](https://www.rfc-editor.org/rfc/rfc8707.html) resource indicators, so FastMCP cannot validate that tokens were issued for the specific resource server. If you need resource-specific audience validation, consider using [WorkOSProvider](/integrations/workos) (OAuth proxy pattern) instead. + ## Configuration ### Prerequisites @@ -62,21 +65,23 @@ To test your server, you can use the `fastmcp` CLI to run it locally. Assuming y fastmcp run server.py --transport http --port 8000 ``` -Now, you can use a FastMCP client to test that you can reach your server after authenticating: +AuthKit defaults DCR clients to `client_secret_basic` for token exchange, which conflicts with how some MCP clients send credentials. To avoid token exchange errors, register as a public client by setting `token_endpoint_auth_method` to `"none"`: -```python +```python client.py from fastmcp import Client +from fastmcp.client.auth import OAuth import asyncio +auth = OAuth(additional_client_metadata={"token_endpoint_auth_method": "none"}) + async def main(): - async with Client("http://localhost:8000/mcp", auth="oauth") as client: + async with Client("http://localhost:8000/mcp", auth=auth) as client: assert await client.ping() if __name__ == "__main__": asyncio.run(main()) ``` - ## Production Configuration For production deployments, load sensitive configuration from environment variables: @@ -93,4 +98,4 @@ auth = AuthKitProvider( ) mcp = FastMCP(name="AuthKit Secured App", auth=auth) -``` \ No newline at end of file +``` diff --git a/docs/integrations/supabase.mdx b/docs/integrations/supabase.mdx index 68b23c89dc..9ffda444d4 100644 --- a/docs/integrations/supabase.mdx +++ b/docs/integrations/supabase.mdx @@ -11,21 +11,43 @@ import { VersionBadge } from "/snippets/version-badge.mdx" This guide shows you how to secure your FastMCP server using **Supabase Auth**. This integration uses the [**Remote OAuth**](/servers/auth/remote-oauth) pattern, where Supabase handles user authentication and your FastMCP server validates the tokens. + +Supabase Auth does not currently support [RFC 8707](https://www.rfc-editor.org/rfc/rfc8707.html) resource indicators, so FastMCP cannot validate that tokens were issued for the specific resource server. + + +## Consent UI Requirement + +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. + ## Configuration ### Prerequisites Before you begin, you will need: 1. A **[Supabase Account](https://supabase.com/)** with a project or a self-hosted **Supabase Auth** instance -2. Your FastMCP server's URL (can be localhost for development, e.g., `http://localhost:8000`) +2. **OAuth Server enabled** in your Supabase Dashboard (Authentication → OAuth Server) +3. **Dynamic Client Registration enabled** in the same settings +4. A **consent UI** hosted at your configured authorization path (see above) +5. Your FastMCP server's URL (can be localhost for development, e.g., `http://localhost:8000`) -### Step 1: Get Supabase Project URL +### Step 1: Enable Supabase OAuth Server + +In your Supabase Dashboard: +1. Go to **Authentication → OAuth Server** +2. Enable the **OAuth Server** +3. Set your **Site URL** to where your consent UI is hosted +4. Set the **Authorization Path** (e.g., `/oauth/callback`) +5. Enable **Allow Dynamic OAuth Apps** for MCP client registration + +### Step 2: Get Supabase Project URL In your Supabase Dashboard: 1. Go to **Project Settings** 2. Copy your **Project URL** (e.g., `https://abc123.supabase.co`) -### Step 2: FastMCP Configuration +### Step 3: FastMCP Configuration Create your FastMCP server using the `SupabaseProvider`: @@ -33,12 +55,9 @@ Create your FastMCP server using the `SupabaseProvider`: from fastmcp import FastMCP from fastmcp.server.auth.providers.supabase import SupabaseProvider -# Configure Supabase Auth auth = SupabaseProvider( project_url="https://abc123.supabase.co", base_url="http://localhost:8000", - # Optional: customize auth_route for self-hosted Supabase Auth with custom routes - # auth_route="/my/auth/route" ) mcp = FastMCP("Supabase Protected Server", auth=auth) @@ -62,8 +81,6 @@ Start your FastMCP server with HTTP transport to enable OAuth flows: fastmcp run server.py --transport http --port 8000 ``` -Your server is now running and protected by Supabase authentication. - ### Testing with a Client Create a test client that authenticates with your Supabase-protected server: @@ -73,12 +90,9 @@ from fastmcp import Client import asyncio async def main(): - # The client will automatically handle Supabase OAuth async with Client("http://localhost:8000/mcp", auth="oauth") as client: - # First-time connection will open Supabase login in your browser - print("✓ Authenticated with Supabase!") + print("Authenticated with Supabase!") - # Test the protected tool result = await client.call_tool("protected_tool", {"message": "Hello!"}) print(result) @@ -87,9 +101,9 @@ if __name__ == "__main__": ``` When you run the client for the first time: -1. Your browser will open to Supabase's authorization page -2. After you authorize, you'll be redirected back -3. The client receives the token and can make authenticated requests +1. Your browser will open to Supabase's authorization endpoint +2. After authenticating, Supabase redirects to your consent UI +3. After you approve, the client receives the token and can make authenticated requests ## Production Configuration @@ -100,11 +114,9 @@ import os from fastmcp import FastMCP from fastmcp.server.auth.providers.supabase import SupabaseProvider -# Load configuration from environment variables auth = SupabaseProvider( project_url=os.environ["SUPABASE_PROJECT_URL"], base_url=os.environ.get("BASE_URL", "https://your-server.com"), - auth_route=os.environ.get("SUPABASE_AUTH_ROUTE", "/auth/v1"), # Optional: for custom routes ) mcp = FastMCP(name="Supabase Secured App", auth=auth) diff --git a/docs/python-sdk/fastmcp-server-auth-providers-scalekit.mdx b/docs/python-sdk/fastmcp-server-auth-providers-scalekit.mdx index a36e35e427..d1908f9cd9 100644 --- a/docs/python-sdk/fastmcp-server-auth-providers-scalekit.mdx +++ b/docs/python-sdk/fastmcp-server-auth-providers-scalekit.mdx @@ -44,7 +44,7 @@ https://docs.scalekit.com/mcp/overview/ **Methods:** -#### `get_routes` +#### `get_routes` ```python get_routes(self, mcp_path: str | None = None) -> list[Route] diff --git a/docs/python-sdk/fastmcp-server-auth-providers-supabase.mdx b/docs/python-sdk/fastmcp-server-auth-providers-supabase.mdx index 7210566bfd..5772a4fa3f 100644 --- a/docs/python-sdk/fastmcp-server-auth-providers-supabase.mdx +++ b/docs/python-sdk/fastmcp-server-auth-providers-supabase.mdx @@ -50,7 +50,7 @@ https://supabase.com/docs/guides/auth/jwts **Methods:** -#### `get_routes` +#### `get_routes` ```python get_routes(self, mcp_path: str | None = None) -> list[Route] diff --git a/docs/python-sdk/fastmcp-server-auth-providers-workos.mdx b/docs/python-sdk/fastmcp-server-auth-providers-workos.mdx index afd05187f3..28fb9c0868 100644 --- a/docs/python-sdk/fastmcp-server-auth-providers-workos.mdx +++ b/docs/python-sdk/fastmcp-server-auth-providers-workos.mdx @@ -85,7 +85,7 @@ https://workos.com/docs/authkit/mcp/integrating/token-verification **Methods:** -#### `get_routes` +#### `get_routes` ```python get_routes(self, mcp_path: str | None = None) -> list[Route] diff --git a/examples/auth/authkit_dcr/README.md b/examples/auth/authkit_dcr/README.md index 884f59ac1e..8082461996 100644 --- a/examples/auth/authkit_dcr/README.md +++ b/examples/auth/authkit_dcr/README.md @@ -7,7 +7,7 @@ Demonstrates FastMCP server protection with AuthKit Dynamic Client Registration. 1. Set your AuthKit domain: ```bash - export FASTMCP_SERVER_AUTH_AUTHKITPROVIDER_AUTHKIT_DOMAIN="https://your-app.authkit.app" + export AUTHKIT_DOMAIN="https://your-app.authkit.app" ``` 2. Run the server: diff --git a/examples/auth/authkit_dcr/client.py b/examples/auth/authkit_dcr/client.py index 5f1f39bb2f..562637bec1 100644 --- a/examples/auth/authkit_dcr/client.py +++ b/examples/auth/authkit_dcr/client.py @@ -9,23 +9,24 @@ import asyncio from fastmcp.client import Client +from fastmcp.client.auth import OAuth SERVER_URL = "http://127.0.0.1:8000/mcp" async def main(): - try: - async with Client(SERVER_URL, auth="oauth") as client: - assert await client.ping() - print("✅ Successfully authenticated!") - - tools = await client.list_tools() - print(f"🔧 Available tools ({len(tools)}):") - for tool in tools: - print(f" - {tool.name}: {tool.description}") - except Exception as e: - print(f"❌ Authentication failed: {e}") - raise + # AuthKit defaults DCR clients to client_secret_basic, which conflicts + # with how MCP SDKs send credentials. Force "none" to register as a + # public client and avoid token exchange errors. + auth = OAuth(additional_client_metadata={"token_endpoint_auth_method": "none"}) + async with Client(SERVER_URL, auth=auth) as client: + assert await client.ping() + print("Successfully authenticated!") + + tools = await client.list_tools() + print(f"Available tools ({len(tools)}):") + for tool in tools: + print(f" - {tool.name}: {tool.description}") if __name__ == "__main__": diff --git a/examples/auth/authkit_dcr/server.py b/examples/auth/authkit_dcr/server.py index c05a676d80..8974376d2b 100644 --- a/examples/auth/authkit_dcr/server.py +++ b/examples/auth/authkit_dcr/server.py @@ -15,8 +15,7 @@ from fastmcp.server.auth.providers.workos import AuthKitProvider auth = AuthKitProvider( - authkit_domain=os.getenv("FASTMCP_SERVER_AUTH_AUTHKITPROVIDER_AUTHKIT_DOMAIN") - or "", + authkit_domain=os.getenv("AUTHKIT_DOMAIN") or "", base_url="http://localhost:8000", ) diff --git a/examples/auth/github_oauth/client.py b/examples/auth/github_oauth/client.py index a7ab5c47ee..7158583bc6 100644 --- a/examples/auth/github_oauth/client.py +++ b/examples/auth/github_oauth/client.py @@ -15,13 +15,7 @@ async def main(): try: - async with Client( - SERVER_URL, - auth=OAuth( - # Replace with your own CIMD document URL - client_metadata_url="https://www.jlowin.dev/mcp-client.json", - ), - ) as client: + async with Client(SERVER_URL, auth=OAuth()) as client: assert await client.ping() print("✅ Successfully authenticated!") diff --git a/src/fastmcp/server/auth/providers/scalekit.py b/src/fastmcp/server/auth/providers/scalekit.py index e434e95d9c..d55ec14fd7 100644 --- a/src/fastmcp/server/auth/providers/scalekit.py +++ b/src/fastmcp/server/auth/providers/scalekit.py @@ -127,6 +127,7 @@ def __init__( 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: diff --git a/src/fastmcp/server/auth/providers/supabase.py b/src/fastmcp/server/auth/providers/supabase.py index 513c025558..b6253e46c2 100644 --- a/src/fastmcp/server/auth/providers/supabase.py +++ b/src/fastmcp/server/auth/providers/supabase.py @@ -103,10 +103,16 @@ def __init__( # 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, ) diff --git a/src/fastmcp/server/auth/providers/workos.py b/src/fastmcp/server/auth/providers/workos.py index f34f770293..4354d405bd 100644 --- a/src/fastmcp/server/auth/providers/workos.py +++ b/src/fastmcp/server/auth/providers/workos.py @@ -252,6 +252,7 @@ def __init__( *, authkit_domain: AnyHttpUrl | str, base_url: AnyHttpUrl | str, + client_id: str | None = None, required_scopes: list[str] | None = None, token_verifier: TokenVerifier | None = None, ): @@ -260,6 +261,9 @@ def __init__( Args: authkit_domain: Your AuthKit domain (e.g., "https://your-app.authkit.app") base_url: Public URL of this FastMCP server + client_id: Your WorkOS project client ID (e.g., "client_01ABC..."). Used to + validate the JWT audience claim. Found in your WorkOS Dashboard under + API Keys. This is the project-level client ID, not individual MCP client IDs. required_scopes: Optional list of scopes to require for all requests token_verifier: Optional token verifier. If None, creates JWT verifier for AuthKit """ @@ -273,10 +277,17 @@ def __init__( # Create default JWT verifier if none provided if token_verifier is None: + logger.warning( + "AuthKitProvider cannot validate token audience for the specific resource " + "because AuthKit does not support RFC 8707 resource indicators. " + "This may leave the server vulnerable to cross-server token replay. " + "Consider using WorkOSProvider (OAuth proxy) for audience-bound tokens." + ) token_verifier = JWTVerifier( jwks_uri=f"{self.authkit_domain}/oauth2/jwks", issuer=self.authkit_domain, algorithm="RS256", + audience=client_id, required_scopes=parsed_scopes, ) diff --git a/tests/server/auth/providers/test_scalekit.py b/tests/server/auth/providers/test_scalekit.py index b112e1c68f..a47840682f 100644 --- a/tests/server/auth/providers/test_scalekit.py +++ b/tests/server/auth/providers/test_scalekit.py @@ -94,7 +94,7 @@ def test_jwt_verifier_configured_correctly(self): assert isinstance(provider.token_verifier, JWTVerifier) assert provider.token_verifier.jwks_uri == "https://my-env.scalekit.com/keys" assert provider.token_verifier.issuer == "https://my-env.scalekit.com" - assert provider.token_verifier.audience is None + assert provider.token_verifier.audience == "sk_resource_456" def test_required_scopes_hooks_into_verifier(self): """Token verifier should enforce required scopes when provided."""