From 82f310fe61dba25de34f2be3e203555ccfc4181d Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Mon, 13 Apr 2026 17:11:17 -0400 Subject: [PATCH] AuthKit: auto-bind token audience to resource URL (RFC 8707) (#3905) --- docs/integrations/authkit.mdx | 37 ++++---- examples/auth/authkit/README.md | 36 ++++++++ .../auth/{authkit_dcr => authkit}/client.py | 0 .../auth/{authkit_dcr => authkit}/server.py | 12 ++- examples/auth/authkit_dcr/README.md | 25 ----- examples/auth/aws_oauth/README.md | 2 +- examples/auth/aws_oauth/client.py | 2 +- examples/auth/aws_oauth/server.py | 2 +- examples/auth/azure_oauth/README.md | 2 +- examples/auth/azure_oauth/server.py | 2 +- examples/auth/clerk_oauth/README.md | 2 +- examples/auth/clerk_oauth/server.py | 2 +- examples/auth/discord_oauth/README.md | 2 +- examples/auth/discord_oauth/server.py | 2 +- examples/auth/github_oauth/README.md | 2 +- examples/auth/github_oauth/client.py | 2 +- examples/auth/github_oauth/server.py | 2 +- examples/auth/google_oauth/README.md | 2 +- examples/auth/google_oauth/server.py | 2 +- examples/auth/keycloak_oauth/README.md | 2 +- examples/auth/keycloak_oauth/client.py | 2 +- examples/auth/keycloak_oauth/server.py | 4 +- examples/auth/mounted/README.md | 12 +-- examples/auth/mounted/server.py | 10 +- examples/auth/propelauth_oauth/README.md | 4 +- examples/auth/propelauth_oauth/server.py | 4 +- examples/auth/scalekit_oauth/README.md | 4 +- examples/auth/scalekit_oauth/server.py | 4 +- examples/auth/workos_oauth/server.py | 2 +- src/fastmcp/server/auth/auth.py | 6 ++ src/fastmcp/server/auth/providers/jwt.py | 38 +++++--- src/fastmcp/server/auth/providers/workos.py | 57 +++++++++--- tests/server/auth/providers/test_workos.py | 92 +++++++++++++++++++ 33 files changed, 265 insertions(+), 114 deletions(-) create mode 100644 examples/auth/authkit/README.md rename examples/auth/{authkit_dcr => authkit}/client.py (100%) rename examples/auth/{authkit_dcr => authkit}/server.py (50%) delete mode 100644 examples/auth/authkit_dcr/README.md diff --git a/docs/integrations/authkit.mdx b/docs/integrations/authkit.mdx index e99f78351..c77175201 100644 --- a/docs/integrations/authkit.mdx +++ b/docs/integrations/authkit.mdx @@ -9,29 +9,32 @@ 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. - +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 with [RFC 8707](https://www.rfc-editor.org/rfc/rfc8707.html) resource indicators: AuthKit issues tokens whose `aud` claim is bound to your server's resource URL, and FastMCP validates that claim automatically. ## Configuration + ### Prerequisites Before you begin, you will need: 1. A **[WorkOS Account](https://workos.com/)** and a new **Project**. 2. An **[AuthKit](https://www.authkit.com/)** instance configured within your WorkOS project. -3. Your FastMCP server's URL (can be localhost for development, e.g., `http://localhost:8000`). +3. Your FastMCP server's URL (can be localhost for development, e.g., `http://127.0.0.1:8000`). -### Step 1: AuthKit Configuration +### Step 1: WorkOS Dashboard -In your WorkOS Dashboard, enable AuthKit and configure the following settings: +In the WorkOS Dashboard, go to **Connect → Configuration** and configure: - - Go to **Applications → Configuration** and enable **Dynamic Client Registration**. This allows MCP clients register with your application automatically. + + Enable **Dynamic Client Registration** (DCR) so MCP clients can register themselves. Alternatively, enable **Client ID Metadata Document** (CIMD) if your clients support it. + - ![Enable Dynamic Client Registration](./images/authkit/enable_dcr.png) + + Add your FastMCP server's resource URL (e.g., `http://127.0.0.1:8000/mcp`) as a valid resource indicator. + + This must exactly match what FastMCP advertises in its protected resource metadata. Start your server first and it will log the correct URL on startup — copy that value. + + Without this step, AuthKit falls back to a default environment-scoped audience and audience validation will fail with a 401. @@ -47,16 +50,18 @@ Create your FastMCP server file and use the `AuthKitProvider` to handle all the from fastmcp import FastMCP from fastmcp.server.auth.providers.workos import AuthKitProvider -# The AuthKitProvider automatically discovers WorkOS endpoints -# and configures JWT token validation +# AuthKitProvider 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://localhost:8000" # Use your actual server URL + base_url="http://127.0.0.1:8000", # Use your actual server URL ) mcp = FastMCP(name="AuthKit Secured App", auth=auth_provider) ``` +When the server starts, it logs the resource URL it is validating against. Paste that URL into your Dashboard's **MCP resource indicators** list. + ## Testing To test your server, you can use the `fastmcp` CLI to run it locally. Assuming you've saved the above code to `server.py` (after replacing the `authkit_domain` and `base_url` with your actual values!), you can run the following command: @@ -75,7 +80,7 @@ import asyncio auth = OAuth(additional_client_metadata={"token_endpoint_auth_method": "none"}) async def main(): - async with Client("http://localhost:8000/mcp", auth=auth) as client: + async with Client("http://127.0.0.1:8000/mcp", auth=auth) as client: assert await client.ping() if __name__ == "__main__": @@ -94,7 +99,7 @@ from fastmcp.server.auth.providers.workos import AuthKitProvider # 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") + base_url=os.environ.get("BASE_URL", "https://your-server.com"), ) mcp = FastMCP(name="AuthKit Secured App", auth=auth) diff --git a/examples/auth/authkit/README.md b/examples/auth/authkit/README.md new file mode 100644 index 000000000..8c4b8a6aa --- /dev/null +++ b/examples/auth/authkit/README.md @@ -0,0 +1,36 @@ +# AuthKit Example + +Protects a FastMCP server with WorkOS AuthKit. The server binds the JWT +`aud` claim to its own resource URL automatically — you just paste that same +URL into the WorkOS Dashboard as a resource indicator. + +## WorkOS Dashboard setup + +In the WorkOS Dashboard for your project, go to **Connect → Configuration** and: + +1. Under **MCP Auth**, enable **Dynamic Client Registration** (or **Client ID + Metadata Document** if your MCP client supports it). +2. Under **MCP resource indicators**, add `http://127.0.0.1:8000/mcp` as a + valid resource indicator. + +## Running + +1. Set your AuthKit domain: + + ```bash + export AUTHKIT_DOMAIN="https://your-app.authkit.app" + ``` + +2. Start the server. It logs the resource URL it's validating against — + that's the URL that must match your dashboard resource indicator: + + ```bash + python server.py + ``` + +3. In another terminal, run the client. Your browser will open for AuthKit + authentication: + + ```bash + python client.py + ``` diff --git a/examples/auth/authkit_dcr/client.py b/examples/auth/authkit/client.py similarity index 100% rename from examples/auth/authkit_dcr/client.py rename to examples/auth/authkit/client.py diff --git a/examples/auth/authkit_dcr/server.py b/examples/auth/authkit/server.py similarity index 50% rename from examples/auth/authkit_dcr/server.py rename to examples/auth/authkit/server.py index 8974376d2..7611ccddf 100644 --- a/examples/auth/authkit_dcr/server.py +++ b/examples/auth/authkit/server.py @@ -1,9 +1,11 @@ -"""AuthKit DCR server example for FastMCP. +"""AuthKit server example for FastMCP. -This example demonstrates how to protect a FastMCP server with AuthKit DCR. +Demonstrates an MCP server secured by WorkOS AuthKit. FastMCP binds the JWT +audience to this server's resource URL automatically; you configure the same +URL as an MCP resource indicator in the WorkOS Dashboard. Required environment variables: -- FASTMCP_SERVER_AUTH_AUTHKITPROVIDER_AUTHKIT_DOMAIN: Your AuthKit domain (e.g., "https://your-app.authkit.app") +- AUTHKIT_DOMAIN: Your AuthKit domain (e.g., "https://your-app.authkit.app") To run: python server.py @@ -16,10 +18,10 @@ from fastmcp.server.auth.providers.workos import AuthKitProvider auth = AuthKitProvider( authkit_domain=os.getenv("AUTHKIT_DOMAIN") or "", - base_url="http://localhost:8000", + base_url="http://127.0.0.1:8000", ) -mcp = FastMCP("AuthKit DCR Example Server", auth=auth) +mcp = FastMCP("AuthKit Example Server", auth=auth) @mcp.tool diff --git a/examples/auth/authkit_dcr/README.md b/examples/auth/authkit_dcr/README.md deleted file mode 100644 index 808246199..000000000 --- a/examples/auth/authkit_dcr/README.md +++ /dev/null @@ -1,25 +0,0 @@ -# AuthKit DCR Example - -Demonstrates FastMCP server protection with AuthKit Dynamic Client Registration. - -## Setup - -1. Set your AuthKit domain: - - ```bash - export AUTHKIT_DOMAIN="https://your-app.authkit.app" - ``` - -2. Run the server: - - ```bash - python server.py - ``` - -3. In another terminal, run the client: - - ```bash - python client.py - ``` - -The client will open your browser for AuthKit authentication. diff --git a/examples/auth/aws_oauth/README.md b/examples/auth/aws_oauth/README.md index 9abff838c..c4e25b1f8 100644 --- a/examples/auth/aws_oauth/README.md +++ b/examples/auth/aws_oauth/README.md @@ -10,7 +10,7 @@ Demonstrates FastMCP server protection with AWS Cognito OAuth. - Create an App Client in your User Pool - Configure the App Client settings: - Enable "Authorization code grant" flow - - Add Callback URL: `http://localhost:8000/auth/callback` + - Add Callback URL: `http://127.0.0.1:8000/auth/callback` - Configure OAuth scopes (at minimum: `openid`) - Note your User Pool ID, App Client ID, Client Secret, and Cognito Domain Prefix diff --git a/examples/auth/aws_oauth/client.py b/examples/auth/aws_oauth/client.py index 4043e6d4f..afcf54fd1 100644 --- a/examples/auth/aws_oauth/client.py +++ b/examples/auth/aws_oauth/client.py @@ -10,7 +10,7 @@ import asyncio from fastmcp.client import Client -SERVER_URL = "http://localhost:8000/mcp" +SERVER_URL = "http://127.0.0.1:8000/mcp" async def main(): diff --git a/examples/auth/aws_oauth/server.py b/examples/auth/aws_oauth/server.py index dfe596a83..261164391 100644 --- a/examples/auth/aws_oauth/server.py +++ b/examples/auth/aws_oauth/server.py @@ -31,7 +31,7 @@ auth = AWSCognitoProvider( or "eu-central-1", client_id=os.getenv("FASTMCP_SERVER_AUTH_AWS_COGNITO_CLIENT_ID") or "", client_secret=os.getenv("FASTMCP_SERVER_AUTH_AWS_COGNITO_CLIENT_SECRET") or "", - base_url="http://localhost:8000", + base_url="http://127.0.0.1:8000", # redirect_path="/custom/callback" ) diff --git a/examples/auth/azure_oauth/README.md b/examples/auth/azure_oauth/README.md index ba0757ca7..98d9ae756 100644 --- a/examples/auth/azure_oauth/README.md +++ b/examples/auth/azure_oauth/README.md @@ -10,7 +10,7 @@ This example demonstrates how to use the Azure OAuth provider with FastMCP serve 2. Click "New registration" and configure: - Name: Your app name - Supported account types: Choose based on your needs - - Redirect URI: `http://localhost:8000/auth/callback` (Web platform) + - Redirect URI: `http://127.0.0.1:8000/auth/callback` (Web platform) 3. After creation, go to "Certificates & secrets" → "New client secret" 4. Note these values from the Overview page: - Application (client) ID diff --git a/examples/auth/azure_oauth/server.py b/examples/auth/azure_oauth/server.py index d214389aa..e0c9e799e 100644 --- a/examples/auth/azure_oauth/server.py +++ b/examples/auth/azure_oauth/server.py @@ -24,7 +24,7 @@ auth = AzureProvider( client_secret=os.getenv("FASTMCP_SERVER_AUTH_AZURE_CLIENT_SECRET") or "", tenant_id=os.getenv("FASTMCP_SERVER_AUTH_AZURE_TENANT_ID") or "", # Required for single-tenant apps - get from Azure Portal - base_url="http://localhost:8000", + base_url="http://127.0.0.1:8000", required_scopes=["read"], # required_scopes is automatically loaded from FASTMCP_SERVER_AUTH_AZURE_REQUIRED_SCOPES # At least one scope is required - use unprefixed scope names from your Azure App (e.g., ["read", "write"]) diff --git a/examples/auth/clerk_oauth/README.md b/examples/auth/clerk_oauth/README.md index 84d2b44b1..9a79ff566 100644 --- a/examples/auth/clerk_oauth/README.md +++ b/examples/auth/clerk_oauth/README.md @@ -9,7 +9,7 @@ Demonstrates FastMCP server protection with Clerk OAuth. - Create or select an application - Go to Developers > OAuth Applications - Create an OAuth application - - Add Authorized redirect URI: `http://localhost:8000/auth/callback` + - Add Authorized redirect URI: `http://127.0.0.1:8000/auth/callback` - Copy the Client ID and Client Secret - Note your instance domain (e.g., `saving-primate-16.clerk.accounts.dev`) diff --git a/examples/auth/clerk_oauth/server.py b/examples/auth/clerk_oauth/server.py index 74b1e4687..e7d080734 100644 --- a/examples/auth/clerk_oauth/server.py +++ b/examples/auth/clerk_oauth/server.py @@ -21,7 +21,7 @@ auth = ClerkProvider( domain=os.getenv("FASTMCP_SERVER_AUTH_CLERK_DOMAIN") or "", client_id=os.getenv("FASTMCP_SERVER_AUTH_CLERK_CLIENT_ID") or "", client_secret=os.getenv("FASTMCP_SERVER_AUTH_CLERK_CLIENT_SECRET") or "", - base_url="http://localhost:8000", + base_url="http://127.0.0.1:8000", # redirect_path="/auth/callback", # Default path - change if using a different callback URL # Optional: specify required scopes (defaults to ["openid", "email", "profile"]) # required_scopes=["openid", "email", "profile", "public_metadata"], diff --git a/examples/auth/discord_oauth/README.md b/examples/auth/discord_oauth/README.md index 74217f833..e757ad84b 100644 --- a/examples/auth/discord_oauth/README.md +++ b/examples/auth/discord_oauth/README.md @@ -8,7 +8,7 @@ Demonstrates FastMCP server protection with Discord OAuth. - Go to https://discord.com/developers/applications - Click "New Application" and give it a name - Go to OAuth2 in the left sidebar - - Add a Redirect URL: `http://localhost:8000/auth/callback` + - Add a Redirect URL: `http://127.0.0.1:8000/auth/callback` - Copy the Client ID and Client Secret 2. Set environment variables: diff --git a/examples/auth/discord_oauth/server.py b/examples/auth/discord_oauth/server.py index 424c97bdb..1e109b76a 100644 --- a/examples/auth/discord_oauth/server.py +++ b/examples/auth/discord_oauth/server.py @@ -18,7 +18,7 @@ from fastmcp.server.auth.providers.discord import DiscordProvider auth = DiscordProvider( client_id=os.getenv("FASTMCP_SERVER_AUTH_DISCORD_CLIENT_ID") or "", client_secret=os.getenv("FASTMCP_SERVER_AUTH_DISCORD_CLIENT_SECRET") or "", - base_url="http://localhost:8000", + base_url="http://127.0.0.1:8000", # redirect_path="/auth/callback", # Default path - change if using a different callback URL ) diff --git a/examples/auth/github_oauth/README.md b/examples/auth/github_oauth/README.md index 557ba7774..dcd5c2205 100644 --- a/examples/auth/github_oauth/README.md +++ b/examples/auth/github_oauth/README.md @@ -6,7 +6,7 @@ Demonstrates FastMCP server protection with GitHub OAuth. 1. Create a GitHub OAuth App: - Go to GitHub Settings > Developer settings > OAuth Apps - - Set Authorization callback URL to: `http://localhost:8000/auth/callback` + - Set Authorization callback URL to: `http://127.0.0.1:8000/auth/callback` - Copy the Client ID and Client Secret 2. Set environment variables: diff --git a/examples/auth/github_oauth/client.py b/examples/auth/github_oauth/client.py index 7158583bc..8722a547c 100644 --- a/examples/auth/github_oauth/client.py +++ b/examples/auth/github_oauth/client.py @@ -10,7 +10,7 @@ import asyncio from fastmcp.client import Client, OAuth -SERVER_URL = "http://localhost:8000/mcp" +SERVER_URL = "http://127.0.0.1:8000/mcp" async def main(): diff --git a/examples/auth/github_oauth/server.py b/examples/auth/github_oauth/server.py index 1f88c6977..e93d6f01a 100644 --- a/examples/auth/github_oauth/server.py +++ b/examples/auth/github_oauth/server.py @@ -18,7 +18,7 @@ from fastmcp.server.auth.providers.github import GitHubProvider auth = GitHubProvider( client_id=os.getenv("FASTMCP_SERVER_AUTH_GITHUB_CLIENT_ID") or "", client_secret=os.getenv("FASTMCP_SERVER_AUTH_GITHUB_CLIENT_SECRET") or "", - base_url="http://localhost:8000", + base_url="http://127.0.0.1:8000", # redirect_path="/auth/callback", # Default path - change if using a different callback URL ) diff --git a/examples/auth/google_oauth/README.md b/examples/auth/google_oauth/README.md index 869718344..82bcd8696 100644 --- a/examples/auth/google_oauth/README.md +++ b/examples/auth/google_oauth/README.md @@ -9,7 +9,7 @@ Demonstrates FastMCP server protection with Google OAuth. - Create or select a project - Go to APIs & Services > Credentials - Create OAuth 2.0 Client ID (Web application) - - Add Authorized redirect URI: `http://localhost:8000/auth/callback` + - Add Authorized redirect URI: `http://127.0.0.1:8000/auth/callback` - Copy the Client ID and Client Secret 2. Set environment variables: diff --git a/examples/auth/google_oauth/server.py b/examples/auth/google_oauth/server.py index 2a5b1c7df..2043ed6c3 100644 --- a/examples/auth/google_oauth/server.py +++ b/examples/auth/google_oauth/server.py @@ -18,7 +18,7 @@ from fastmcp.server.auth.providers.google import GoogleProvider auth = GoogleProvider( client_id=os.getenv("FASTMCP_SERVER_AUTH_GOOGLE_CLIENT_ID") or "", client_secret=os.getenv("FASTMCP_SERVER_AUTH_GOOGLE_CLIENT_SECRET") or "", - base_url="http://localhost:8000", + base_url="http://127.0.0.1:8000", # redirect_path="/auth/callback", # Default path - change if using a different callback URL # Optional: specify required scopes # required_scopes=["openid", "https://www.googleapis.com/auth/userinfo.email"], diff --git a/examples/auth/keycloak_oauth/README.md b/examples/auth/keycloak_oauth/README.md index 68bdfeda6..ba6b95bf4 100644 --- a/examples/auth/keycloak_oauth/README.md +++ b/examples/auth/keycloak_oauth/README.md @@ -6,7 +6,7 @@ Demonstrates FastMCP server protection with Keycloak OAuth. ## Setup -1. Configure a Keycloak realm with Dynamic Client Registration enabled and a trusted host policy for your server URL (e.g. `http://localhost:8000/*`). +1. Configure a Keycloak realm with Dynamic Client Registration enabled and a trusted host policy for your server URL (e.g. `http://127.0.0.1:8000/*`). 2. Set environment variables: diff --git a/examples/auth/keycloak_oauth/client.py b/examples/auth/keycloak_oauth/client.py index e180f1c56..4992abbab 100644 --- a/examples/auth/keycloak_oauth/client.py +++ b/examples/auth/keycloak_oauth/client.py @@ -8,7 +8,7 @@ import asyncio from fastmcp import Client -SERVER_URL = "http://localhost:8000/mcp" +SERVER_URL = "http://127.0.0.1:8000/mcp" async def main(): diff --git a/examples/auth/keycloak_oauth/server.py b/examples/auth/keycloak_oauth/server.py index f236bdcd3..7b4653103 100644 --- a/examples/auth/keycloak_oauth/server.py +++ b/examples/auth/keycloak_oauth/server.py @@ -16,8 +16,8 @@ from fastmcp.server.dependencies import get_access_token auth = KeycloakAuthProvider( realm_url=os.getenv("KEYCLOAK_REALM_URL") or "http://localhost:8080/realms/fastmcp", - base_url="http://localhost:8000", - # audience="http://localhost:8000", # Recommended for production + base_url="http://127.0.0.1:8000", + # audience="http://127.0.0.1:8000", # Recommended for production ) mcp = FastMCP("Keycloak Example Server", auth=auth) diff --git a/examples/auth/mounted/README.md b/examples/auth/mounted/README.md index 5810dab4c..2bf213094 100644 --- a/examples/auth/mounted/README.md +++ b/examples/auth/mounted/README.md @@ -4,12 +4,12 @@ This example demonstrates mounting multiple OAuth-protected MCP servers in a sin ## URL Structure -- **GitHub MCP**: `http://localhost:8000/api/mcp/github/mcp` -- **Google MCP**: `http://localhost:8000/api/mcp/google/mcp` +- **GitHub MCP**: `http://127.0.0.1:8000/api/mcp/github/mcp` +- **Google MCP**: `http://127.0.0.1:8000/api/mcp/google/mcp` Discovery endpoints (RFC 8414 path-aware): -- **GitHub**: `http://localhost:8000/.well-known/oauth-authorization-server/api/mcp/github` -- **Google**: `http://localhost:8000/.well-known/oauth-authorization-server/api/mcp/google` +- **GitHub**: `http://127.0.0.1:8000/.well-known/oauth-authorization-server/api/mcp/github` +- **Google**: `http://127.0.0.1:8000/.well-known/oauth-authorization-server/api/mcp/google` ## Setup @@ -23,8 +23,8 @@ export FASTMCP_SERVER_AUTH_GOOGLE_CLIENT_SECRET="your-google-client-secret" ``` Configure redirect URIs in each provider's developer console (note the `/api/mcp/{provider}` prefix since the servers are mounted): -- GitHub: `http://localhost:8000/api/mcp/github/auth/callback/github` -- Google: `http://localhost:8000/api/mcp/google/auth/callback/google` +- GitHub: `http://127.0.0.1:8000/api/mcp/github/auth/callback/github` +- Google: `http://127.0.0.1:8000/api/mcp/google/auth/callback/google` ## Running diff --git a/examples/auth/mounted/server.py b/examples/auth/mounted/server.py index 24aefdd59..c5b3af593 100644 --- a/examples/auth/mounted/server.py +++ b/examples/auth/mounted/server.py @@ -5,10 +5,10 @@ application, each with its own provider. It showcases RFC 8414 path-aware discov where each server has its own authorization server metadata endpoint. URL structure: -- GitHub MCP: http://localhost:8000/api/mcp/github/mcp -- Google MCP: http://localhost:8000/api/mcp/google/mcp -- GitHub discovery: http://localhost:8000/.well-known/oauth-authorization-server/api/mcp/github -- Google discovery: http://localhost:8000/.well-known/oauth-authorization-server/api/mcp/google +- GitHub MCP: http://127.0.0.1:8000/api/mcp/github/mcp +- Google MCP: http://127.0.0.1:8000/api/mcp/google/mcp +- GitHub discovery: http://127.0.0.1:8000/.well-known/oauth-authorization-server/api/mcp/github +- Google discovery: http://127.0.0.1:8000/.well-known/oauth-authorization-server/api/mcp/google Required environment variables: - FASTMCP_SERVER_AUTH_GITHUB_CLIENT_ID: Your GitHub OAuth app client ID @@ -31,7 +31,7 @@ from fastmcp.server.auth.providers.github import GitHubProvider from fastmcp.server.auth.providers.google import GoogleProvider # Configuration -ROOT_URL = "http://localhost:8000" +ROOT_URL = "http://127.0.0.1:8000" API_PREFIX = "/api/mcp" # --- GitHub OAuth Server --- diff --git a/examples/auth/propelauth_oauth/README.md b/examples/auth/propelauth_oauth/README.md index 575ea7314..aa5b10ecf 100644 --- a/examples/auth/propelauth_oauth/README.md +++ b/examples/auth/propelauth_oauth/README.md @@ -36,7 +36,7 @@ Create a `.env` file: PROPELAUTH_AUTH_URL=https://auth.yourdomain.com PROPELAUTH_INTROSPECTION_CLIENT_ID=your-client-id PROPELAUTH_INTROSPECTION_CLIENT_SECRET=your-client-secret -BASE_URL=http://localhost:8000/ +BASE_URL=http://127.0.0.1:8000/ # Optional: additional scopes tokens must include (comma-separated) # PROPELAUTH_REQUIRED_SCOPES=read:user_data ``` @@ -50,7 +50,7 @@ Start the server: uv run python server.py ``` -The server will start on `http://localhost:8000/mcp` with PropelAuth OAuth authentication enabled. +The server will start on `http://127.0.0.1:8000/mcp` with PropelAuth OAuth authentication enabled. Test with client: diff --git a/examples/auth/propelauth_oauth/server.py b/examples/auth/propelauth_oauth/server.py index 8401882aa..ab1661d22 100644 --- a/examples/auth/propelauth_oauth/server.py +++ b/examples/auth/propelauth_oauth/server.py @@ -9,7 +9,7 @@ Required environment variables: Optional: - PROPELAUTH_REQUIRED_SCOPES: Comma-separated scopes tokens must include -- BASE_URL: Public URL where the FastMCP server is exposed (defaults to `http://localhost:8000/`) +- BASE_URL: Public URL where the FastMCP server is exposed (defaults to `http://127.0.0.1:8000/`) To run: python server.py @@ -29,7 +29,7 @@ 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.getenv("BASE_URL", "http://localhost:8000/"), + base_url=os.getenv("BASE_URL", "http://127.0.0.1:8000/"), ) mcp = FastMCP("PropelAuth OAuth Example Server", auth=auth) diff --git a/examples/auth/scalekit_oauth/README.md b/examples/auth/scalekit_oauth/README.md index c241d76f7..d16b81c37 100644 --- a/examples/auth/scalekit_oauth/README.md +++ b/examples/auth/scalekit_oauth/README.md @@ -24,7 +24,7 @@ Create a `.env` file: # Required Scalekit credentials SCALEKIT_ENVIRONMENT_URL= SCALEKIT_RESOURCE_ID= # res_926EXAMPLE5878 -BASE_URL=http://localhost:8000/ +BASE_URL=http://127.0.0.1:8000/ # Optional: additional scopes tokens must include (comma-separated) # SCALEKIT_REQUIRED_SCOPES=read,write ``` @@ -38,7 +38,7 @@ Start the server: uv run python server.py ``` -The server will start on `http://localhost:8000/mcp` with Scalekit OAuth authentication enabled. +The server will start on `http://127.0.0.1:8000/mcp` with Scalekit OAuth authentication enabled. Test with client: diff --git a/examples/auth/scalekit_oauth/server.py b/examples/auth/scalekit_oauth/server.py index 68cef23b5..09d4f5959 100644 --- a/examples/auth/scalekit_oauth/server.py +++ b/examples/auth/scalekit_oauth/server.py @@ -8,7 +8,7 @@ Required environment variables: Optional: - SCALEKIT_REQUIRED_SCOPES: Comma-separated scopes tokens must include -- BASE_URL: Public URL where the FastMCP server is exposed (defaults to `http://localhost:8000/`) +- BASE_URL: Public URL where the FastMCP server is exposed (defaults to `http://127.0.0.1:8000/`) To run: python server.py @@ -30,7 +30,7 @@ auth = ScalekitProvider( environment_url=os.getenv("SCALEKIT_ENVIRONMENT_URL") or "https://your-env.scalekit.com", resource_id=os.getenv("SCALEKIT_RESOURCE_ID") or "", - base_url=os.getenv("BASE_URL", "http://localhost:8000/"), + base_url=os.getenv("BASE_URL", "http://127.0.0.1:8000/"), required_scopes=required_scopes, ) diff --git a/examples/auth/workos_oauth/server.py b/examples/auth/workos_oauth/server.py index 08c1db62b..4dba970a8 100644 --- a/examples/auth/workos_oauth/server.py +++ b/examples/auth/workos_oauth/server.py @@ -20,7 +20,7 @@ auth = WorkOSProvider( client_id=os.getenv("WORKOS_CLIENT_ID") or "", client_secret=os.getenv("WORKOS_CLIENT_SECRET") or "", authkit_domain=os.getenv("WORKOS_AUTHKIT_DOMAIN") or "https://your-app.authkit.app", - base_url="http://localhost:8000", + base_url="http://127.0.0.1:8000", # redirect_path="/auth/callback", # Default path - change if using a different callback URL ) diff --git a/src/fastmcp/server/auth/auth.py b/src/fastmcp/server/auth/auth.py index c060c0fa6..852f440a4 100644 --- a/src/fastmcp/server/auth/auth.py +++ b/src/fastmcp/server/auth/auth.py @@ -477,6 +477,12 @@ class RemoteAuthProvider(AuthProvider): Creates protected resource metadata routes (RFC 9728). """ + # Lifecycle hook: let subclasses react to the mcp_path becoming known + # (e.g., bind token audience to the resource URL). Mirrors the call in + # OAuthAuthorizationServerProvider.get_routes so all providers see the + # path at the same point in their lifecycle. + self.set_mcp_path(mcp_path) + routes = [] # Get the resource URL based on the MCP path diff --git a/src/fastmcp/server/auth/providers/jwt.py b/src/fastmcp/server/auth/providers/jwt.py index 17194329f..2417cd067 100644 --- a/src/fastmcp/server/auth/providers/jwt.py +++ b/src/fastmcp/server/auth/providers/jwt.py @@ -419,13 +419,16 @@ class JWTVerifier(TokenVerifier): or "unknown" ) - # Validate expiration + # Validate expiration. Kept at INFO (not WARNING like issuer/ + # audience/scope mismatches below) — expiry is expected-path noise + # from normal token rotation, not a configuration error worth + # surfacing by default. exp = claims.get("exp") if exp is not None and exp < time.time(): - self.logger.debug( - "Token validation failed: expired token for client %s", client_id + self.logger.info( + "Bearer token rejected for client %s: token expired", + client_id, ) - self.logger.info("Bearer token rejected for client %s", client_id) return None # Validate issuer - note we use issuer instead of issuer_url here because @@ -443,11 +446,13 @@ class JWTVerifier(TokenVerifier): issuer_valid = iss == self.issuer if not issuer_valid: - self.logger.debug( - "Token validation failed: issuer mismatch for client %s", + self.logger.warning( + "Bearer token rejected for client %s: issuer mismatch " + "(got %r, expected %r)", client_id, + iss, + self.issuer, ) - self.logger.info("Bearer token rejected for client %s", client_id) return None # Validate audience if configured @@ -474,11 +479,13 @@ class JWTVerifier(TokenVerifier): audience_valid = aud == self.audience if not audience_valid: - self.logger.debug( - "Token validation failed: audience mismatch for client %s", + self.logger.warning( + "Bearer token rejected for client %s: audience mismatch " + "(got %r, expected %r)", client_id, + aud, + self.audience, ) - self.logger.info("Bearer token rejected for client %s", client_id) return None # Extract scopes @@ -489,12 +496,13 @@ class JWTVerifier(TokenVerifier): token_scopes = set(scopes) required_scopes = set(self.required_scopes) if not required_scopes.issubset(token_scopes): - self.logger.debug( - "Token missing required scopes. Has: %s, Required: %s", - token_scopes, - required_scopes, + self.logger.warning( + "Bearer token rejected for client %s: missing required " + "scopes (has %s, requires %s)", + client_id, + sorted(token_scopes), + sorted(required_scopes), ) - self.logger.info("Bearer token rejected for client %s", client_id) return None return AccessToken( diff --git a/src/fastmcp/server/auth/providers/workos.py b/src/fastmcp/server/auth/providers/workos.py index a29e331fb..bd6d582ad 100644 --- a/src/fastmcp/server/auth/providers/workos.py +++ b/src/fastmcp/server/auth/providers/workos.py @@ -277,17 +277,22 @@ class AuthKitProvider(RemoteAuthProvider): 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 - # Create AuthKit metadata provider (JWT verifier created automatically) workos_auth = AuthKitProvider( authkit_domain="https://your-workos-domain.authkit.app", base_url="https://your-fastmcp-server.com", ) - # Use with FastMCP mcp = FastMCP("My App", auth=workos_auth) ``` """ @@ -297,7 +302,7 @@ class AuthKitProvider(RemoteAuthProvider): *, authkit_domain: AnyHttpUrl | str, base_url: AnyHttpUrl | str, - client_id: str | None = None, + resource_base_url: AnyHttpUrl | str | None = None, required_scopes: list[str] | None = None, scopes_supported: list[str] | None = None, resource_name: str | None = None, @@ -309,16 +314,20 @@ class AuthKitProvider(RemoteAuthProvider): 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. + 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 None, creates JWT verifier for AuthKit + 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("/")) @@ -328,19 +337,14 @@ class AuthKitProvider(RemoteAuthProvider): parse_scopes(required_scopes) if required_scopes is not None else None ) - # Create default JWT verifier if none provided + # 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: - 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, ) @@ -349,11 +353,34 @@ class AuthKitProvider(RemoteAuthProvider): 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, diff --git a/tests/server/auth/providers/test_workos.py b/tests/server/auth/providers/test_workos.py index cc6ac742a..2e87956c1 100644 --- a/tests/server/auth/providers/test_workos.py +++ b/tests/server/auth/providers/test_workos.py @@ -9,6 +9,7 @@ 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, WorkOSProvider, @@ -173,6 +174,97 @@ class TestAuthKitProvider: # assert "add" in tools +class TestAuthKitAudienceBinding: + """RFC 8707 resource-indicator audience binding. + + AuthKit mints tokens with ``aud`` equal to the resource URL the client + requested — which must equal the URL FastMCP advertises in its protected + resource metadata. AuthKitProvider auto-wires that equality: once the + MCP mount path is known, ``JWTVerifier.audience`` is set to + ``_get_resource_url(mcp_path)``. + """ + + def test_audience_binds_to_resource_url_on_set_mcp_path(self): + provider = AuthKitProvider( + authkit_domain="https://test.authkit.app", + base_url="http://127.0.0.1:8000", + ) + + verifier = provider.token_verifier + assert isinstance(verifier, JWTVerifier) + # Audience unset before the path is known — provider has no way to + # compute the resource URL yet. + assert verifier.audience is None + + provider.set_mcp_path("/mcp") + + expected = str(provider._get_resource_url("/mcp")) + assert verifier.audience == expected + assert expected == "http://127.0.0.1:8000/mcp" + + def test_set_mcp_path_none_binds_to_base_url(self): + """When no MCP path is provided, the resource URL is ``base_url`` + itself (an MCP-at-root server) and the audience binds to that.""" + provider = AuthKitProvider( + authkit_domain="https://test.authkit.app", + base_url="http://127.0.0.1:8000", + ) + + provider.set_mcp_path(None) + + verifier = provider.token_verifier + assert isinstance(verifier, JWTVerifier) + # Matches _get_resource_url(None) which returns base_url unchanged. + assert verifier.audience == "http://127.0.0.1:8000/" + + def test_audience_respects_resource_base_url(self): + """When ``resource_base_url`` differs from ``base_url``, the audience + follows the advertised resource URL, not the OAuth-surface URL.""" + provider = AuthKitProvider( + authkit_domain="https://test.authkit.app", + base_url="https://oauth.example.com", + resource_base_url="https://api.example.com", + ) + provider.set_mcp_path("/mcp") + + verifier = provider.token_verifier + assert isinstance(verifier, JWTVerifier) + assert verifier.audience == "https://api.example.com/mcp" + + def test_custom_token_verifier_audience_not_overwritten(self): + """If the caller supplies their own verifier, we treat its audience + as intentional and do not touch it.""" + custom_audience = "https://some-other-resource.example.com" + custom = JWTVerifier( + jwks_uri="https://test.authkit.app/oauth2/jwks", + issuer="https://test.authkit.app", + audience=custom_audience, + ) + provider = AuthKitProvider( + authkit_domain="https://test.authkit.app", + base_url="http://127.0.0.1:8000", + token_verifier=custom, + ) + provider.set_mcp_path("/mcp") + + assert provider.token_verifier is custom + assert custom.audience == custom_audience + + def test_audience_binds_through_http_app(self): + """End-to-end: mounting a FastMCP server triggers the lifecycle hook + that populates ``JWTVerifier.audience``.""" + auth = AuthKitProvider( + authkit_domain="https://test.authkit.app", + base_url="http://127.0.0.1:8000", + ) + mcp = FastMCP("test", auth=auth) + mcp.http_app(path="/mcp") + + verifier = auth.token_verifier + assert isinstance(verifier, JWTVerifier) + assert verifier.audience == "http://127.0.0.1:8000/mcp" + + class TestWorkOSTokenVerifierScopes: async def test_verify_token_rejects_missing_required_scopes( self, httpx_mock: HTTPXMock