mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-21 04:54:17 +02:00
AuthKit: auto-bind token audience to resource URL (RFC 8707) (#3905)
This commit is contained in:
parent
c2dafc1c88
commit
82f310fe61
33 changed files with 265 additions and 114 deletions
|
|
@ -9,29 +9,32 @@ import { VersionBadge } from "/snippets/version-badge.mdx"
|
|||
|
||||
<VersionBadge version="2.11.0" />
|
||||
|
||||
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.
|
||||
|
||||
<Warning>
|
||||
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.
|
||||
</Warning>
|
||||
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:
|
||||
|
||||
<Steps>
|
||||
<Step title="Enable Dynamic Client Registration">
|
||||
Go to **Applications → Configuration** and enable **Dynamic Client Registration**. This allows MCP clients register with your application automatically.
|
||||
<Step title="MCP Auth">
|
||||
Enable **Dynamic Client Registration** (DCR) so MCP clients can register themselves. Alternatively, enable **Client ID Metadata Document** (CIMD) if your clients support it.
|
||||
</Step>
|
||||
|
||||

|
||||
<Step title="MCP resource indicators">
|
||||
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.
|
||||
</Step>
|
||||
|
||||
<Step title="Note Your AuthKit Domain">
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
36
examples/auth/authkit/README.md
Normal file
36
examples/auth/authkit/README.md
Normal file
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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`)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ---
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ Create a `.env` file:
|
|||
# Required Scalekit credentials
|
||||
SCALEKIT_ENVIRONMENT_URL=<YOUR_APP_ENVIRONMENT_URL>
|
||||
SCALEKIT_RESOURCE_ID=<YOUR_APP_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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue