AuthKit: auto-bind token audience to resource URL (RFC 8707) (#3905)

This commit is contained in:
Jeremiah Lowin 2026-04-13 17:11:17 -04:00 committed by William Easton
commit 6f045972ab
No known key found for this signature in database
33 changed files with 265 additions and 114 deletions

View file

@ -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>
![Enable Dynamic Client Registration](./images/authkit/enable_dcr.png)
<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)

View 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
```

View file

@ -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

View file

@ -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.

View file

@ -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

View file

@ -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():

View file

@ -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"
)

View file

@ -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

View file

@ -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"])

View file

@ -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`)

View file

@ -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"],

View file

@ -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:

View file

@ -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
)

View file

@ -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:

View file

@ -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():

View file

@ -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
)

View file

@ -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:

View file

@ -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"],

View file

@ -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:

View file

@ -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():

View file

@ -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)

View file

@ -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

View file

@ -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 ---

View file

@ -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:

View file

@ -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)

View file

@ -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:

View file

@ -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,
)

View file

@ -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
)

View file

@ -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

View file

@ -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(

View file

@ -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,

View file

@ -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