Spaces:
Running
Running
Jeremiah Lowin commited on
Add complete support for TokenVerifier protocol (#1297)
Browse files- docs/docs.json +6 -22
- docs/integrations/anthropic.mdx +7 -7
- docs/integrations/openai.mdx +7 -7
- docs/servers/auth/{bearer.mdx → verifiers.mdx} +134 -40
- docs/servers/server.mdx +4 -0
- src/fastmcp/contrib/component_manager/example.py +2 -2
- src/fastmcp/server/auth/__init__.py +18 -2
- src/fastmcp/server/auth/auth.py +32 -18
- src/fastmcp/server/auth/providers/bearer.py +25 -482
- src/fastmcp/server/auth/providers/bearer_env.py +0 -65
- src/fastmcp/server/auth/verifiers.py +718 -0
- src/fastmcp/server/http.py +56 -28
- src/fastmcp/server/server.py +6 -5
- src/fastmcp/settings.py +1 -1
- tests/auth/providers/test_token_verifier.py +19 -23
- tests/auth/{providers/test_bearer_env.py → verifiers/test_env_jwt.py} +25 -27
- tests/auth/{providers/test_bearer.py → verifiers/test_jwt_verifier.py} +43 -45
- tests/contrib/test_component_manager.py +6 -7
- tests/deprecated/test_bearer_auth_provider.py +13 -0
- tests/server/auth/test_token_verifier_integration.py +142 -0
- tests/server/http/test_auth_setup.py +17 -19
- tests/server/http/test_bearer_auth_backend.py +15 -15
- tests/server/http/test_http_auth_middleware.py +5 -5
docs/docs.json
CHANGED
|
@@ -65,10 +65,7 @@
|
|
| 65 |
{
|
| 66 |
"group": "Essentials",
|
| 67 |
"icon": "cube",
|
| 68 |
-
"pages": [
|
| 69 |
-
"servers/server",
|
| 70 |
-
"deployment/running-server"
|
| 71 |
-
]
|
| 72 |
},
|
| 73 |
{
|
| 74 |
"group": "Core Components",
|
|
@@ -96,9 +93,7 @@
|
|
| 96 |
{
|
| 97 |
"group": "Authentication",
|
| 98 |
"icon": "shield-check",
|
| 99 |
-
"pages": [
|
| 100 |
-
"servers/auth/bearer"
|
| 101 |
-
]
|
| 102 |
}
|
| 103 |
]
|
| 104 |
},
|
|
@@ -108,10 +103,7 @@
|
|
| 108 |
{
|
| 109 |
"group": "Essentials",
|
| 110 |
"icon": "cube",
|
| 111 |
-
"pages": [
|
| 112 |
-
"clients/client",
|
| 113 |
-
"clients/transports"
|
| 114 |
-
]
|
| 115 |
},
|
| 116 |
{
|
| 117 |
"group": "Core Operations",
|
|
@@ -137,10 +129,7 @@
|
|
| 137 |
{
|
| 138 |
"group": "Authentication",
|
| 139 |
"icon": "user-shield",
|
| 140 |
-
"pages": [
|
| 141 |
-
"clients/auth/oauth",
|
| 142 |
-
"clients/auth/bearer"
|
| 143 |
-
]
|
| 144 |
}
|
| 145 |
]
|
| 146 |
},
|
|
@@ -186,17 +175,12 @@
|
|
| 186 |
},
|
| 187 |
{
|
| 188 |
"anchor": "What's New",
|
| 189 |
-
"pages": [
|
| 190 |
-
"updates",
|
| 191 |
-
"changelog"
|
| 192 |
-
]
|
| 193 |
},
|
| 194 |
{
|
| 195 |
"anchor": "Community",
|
| 196 |
"icon": "users",
|
| 197 |
-
"pages": [
|
| 198 |
-
"community/showcase"
|
| 199 |
-
]
|
| 200 |
}
|
| 201 |
]
|
| 202 |
},
|
|
|
|
| 65 |
{
|
| 66 |
"group": "Essentials",
|
| 67 |
"icon": "cube",
|
| 68 |
+
"pages": ["servers/server", "deployment/running-server"]
|
|
|
|
|
|
|
|
|
|
| 69 |
},
|
| 70 |
{
|
| 71 |
"group": "Core Components",
|
|
|
|
| 93 |
{
|
| 94 |
"group": "Authentication",
|
| 95 |
"icon": "shield-check",
|
| 96 |
+
"pages": ["servers/auth/verifiers"]
|
|
|
|
|
|
|
| 97 |
}
|
| 98 |
]
|
| 99 |
},
|
|
|
|
| 103 |
{
|
| 104 |
"group": "Essentials",
|
| 105 |
"icon": "cube",
|
| 106 |
+
"pages": ["clients/client", "clients/transports"]
|
|
|
|
|
|
|
|
|
|
| 107 |
},
|
| 108 |
{
|
| 109 |
"group": "Core Operations",
|
|
|
|
| 129 |
{
|
| 130 |
"group": "Authentication",
|
| 131 |
"icon": "user-shield",
|
| 132 |
+
"pages": ["clients/auth/oauth", "clients/auth/bearer"]
|
|
|
|
|
|
|
|
|
|
| 133 |
}
|
| 134 |
]
|
| 135 |
},
|
|
|
|
| 175 |
},
|
| 176 |
{
|
| 177 |
"anchor": "What's New",
|
| 178 |
+
"pages": ["updates", "changelog"]
|
|
|
|
|
|
|
|
|
|
| 179 |
},
|
| 180 |
{
|
| 181 |
"anchor": "Community",
|
| 182 |
"icon": "users",
|
| 183 |
+
"pages": ["community/showcase"]
|
|
|
|
|
|
|
| 184 |
}
|
| 185 |
]
|
| 186 |
},
|
docs/integrations/anthropic.mdx
CHANGED
|
@@ -125,7 +125,7 @@ For this example, we'll quickly generate our own tokens with FastMCP's `RSAKeyPa
|
|
| 125 |
We'll start by creating an RSA key pair to sign and verify tokens.
|
| 126 |
|
| 127 |
```python
|
| 128 |
-
from fastmcp.server.auth.
|
| 129 |
|
| 130 |
key_pair = RSAKeyPair.generate()
|
| 131 |
access_token = key_pair.create_token(audience="dice-server")
|
|
@@ -135,13 +135,13 @@ access_token = key_pair.create_token(audience="dice-server")
|
|
| 135 |
FastMCP's `RSAKeyPair` utility is for development and testing only.
|
| 136 |
</Warning>
|
| 137 |
|
| 138 |
-
Next, we'll create a `
|
| 139 |
|
| 140 |
```python
|
| 141 |
from fastmcp import FastMCP
|
| 142 |
-
from fastmcp.server.auth import
|
| 143 |
|
| 144 |
-
auth =
|
| 145 |
public_key=key_pair.public_key,
|
| 146 |
audience="dice-server",
|
| 147 |
)
|
|
@@ -153,14 +153,14 @@ Here is a complete example that you can copy/paste. For simplicity and the purpo
|
|
| 153 |
|
| 154 |
```python server.py [expandable]
|
| 155 |
from fastmcp import FastMCP
|
| 156 |
-
from fastmcp.server.auth import
|
| 157 |
-
from fastmcp.server.auth.
|
| 158 |
import random
|
| 159 |
|
| 160 |
key_pair = RSAKeyPair.generate()
|
| 161 |
access_token = key_pair.create_token(audience="dice-server")
|
| 162 |
|
| 163 |
-
auth =
|
| 164 |
public_key=key_pair.public_key,
|
| 165 |
audience="dice-server",
|
| 166 |
)
|
|
|
|
| 125 |
We'll start by creating an RSA key pair to sign and verify tokens.
|
| 126 |
|
| 127 |
```python
|
| 128 |
+
from fastmcp.server.auth.verifiers import RSAKeyPair
|
| 129 |
|
| 130 |
key_pair = RSAKeyPair.generate()
|
| 131 |
access_token = key_pair.create_token(audience="dice-server")
|
|
|
|
| 135 |
FastMCP's `RSAKeyPair` utility is for development and testing only.
|
| 136 |
</Warning>
|
| 137 |
|
| 138 |
+
Next, we'll create a `JWTVerifier` to authenticate the server.
|
| 139 |
|
| 140 |
```python
|
| 141 |
from fastmcp import FastMCP
|
| 142 |
+
from fastmcp.server.auth import JWTVerifier
|
| 143 |
|
| 144 |
+
auth = JWTVerifier(
|
| 145 |
public_key=key_pair.public_key,
|
| 146 |
audience="dice-server",
|
| 147 |
)
|
|
|
|
| 153 |
|
| 154 |
```python server.py [expandable]
|
| 155 |
from fastmcp import FastMCP
|
| 156 |
+
from fastmcp.server.auth import JWTVerifier
|
| 157 |
+
from fastmcp.server.auth.verifiers import RSAKeyPair
|
| 158 |
import random
|
| 159 |
|
| 160 |
key_pair = RSAKeyPair.generate()
|
| 161 |
access_token = key_pair.create_token(audience="dice-server")
|
| 162 |
|
| 163 |
+
auth = JWTVerifier(
|
| 164 |
public_key=key_pair.public_key,
|
| 165 |
audience="dice-server",
|
| 166 |
)
|
docs/integrations/openai.mdx
CHANGED
|
@@ -123,7 +123,7 @@ For this example, we'll quickly generate our own tokens with FastMCP's `RSAKeyPa
|
|
| 123 |
We'll start by creating an RSA key pair to sign and verify tokens.
|
| 124 |
|
| 125 |
```python
|
| 126 |
-
from fastmcp.server.auth.
|
| 127 |
|
| 128 |
key_pair = RSAKeyPair.generate()
|
| 129 |
access_token = key_pair.create_token(audience="dice-server")
|
|
@@ -133,13 +133,13 @@ access_token = key_pair.create_token(audience="dice-server")
|
|
| 133 |
FastMCP's `RSAKeyPair` utility is for development and testing only.
|
| 134 |
</Warning>
|
| 135 |
|
| 136 |
-
Next, we'll create a `
|
| 137 |
|
| 138 |
```python
|
| 139 |
from fastmcp import FastMCP
|
| 140 |
-
from fastmcp.server.auth import
|
| 141 |
|
| 142 |
-
auth =
|
| 143 |
public_key=key_pair.public_key,
|
| 144 |
audience="dice-server",
|
| 145 |
)
|
|
@@ -151,14 +151,14 @@ Here is a complete example that you can copy/paste. For simplicity and the purpo
|
|
| 151 |
|
| 152 |
```python server.py [expandable]
|
| 153 |
from fastmcp import FastMCP
|
| 154 |
-
from fastmcp.server.auth import
|
| 155 |
-
from fastmcp.server.auth.
|
| 156 |
import random
|
| 157 |
|
| 158 |
key_pair = RSAKeyPair.generate()
|
| 159 |
access_token = key_pair.create_token(audience="dice-server")
|
| 160 |
|
| 161 |
-
auth =
|
| 162 |
public_key=key_pair.public_key,
|
| 163 |
audience="dice-server",
|
| 164 |
)
|
|
|
|
| 123 |
We'll start by creating an RSA key pair to sign and verify tokens.
|
| 124 |
|
| 125 |
```python
|
| 126 |
+
from fastmcp.server.auth.verifiers import RSAKeyPair
|
| 127 |
|
| 128 |
key_pair = RSAKeyPair.generate()
|
| 129 |
access_token = key_pair.create_token(audience="dice-server")
|
|
|
|
| 133 |
FastMCP's `RSAKeyPair` utility is for development and testing only.
|
| 134 |
</Warning>
|
| 135 |
|
| 136 |
+
Next, we'll create a `JWTVerifier` to authenticate the server.
|
| 137 |
|
| 138 |
```python
|
| 139 |
from fastmcp import FastMCP
|
| 140 |
+
from fastmcp.server.auth import JWTVerifier
|
| 141 |
|
| 142 |
+
auth = JWTVerifier(
|
| 143 |
public_key=key_pair.public_key,
|
| 144 |
audience="dice-server",
|
| 145 |
)
|
|
|
|
| 151 |
|
| 152 |
```python server.py [expandable]
|
| 153 |
from fastmcp import FastMCP
|
| 154 |
+
from fastmcp.server.auth import JWTVerifier
|
| 155 |
+
from fastmcp.server.auth.verifiers import RSAKeyPair
|
| 156 |
import random
|
| 157 |
|
| 158 |
key_pair = RSAKeyPair.generate()
|
| 159 |
access_token = key_pair.create_token(audience="dice-server")
|
| 160 |
|
| 161 |
+
auth = JWTVerifier(
|
| 162 |
public_key=key_pair.public_key,
|
| 163 |
audience="dice-server",
|
| 164 |
)
|
docs/servers/auth/{bearer.mdx → verifiers.mdx}
RENAMED
|
@@ -1,22 +1,19 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
sidebarTitle:
|
| 4 |
-
description: Secure your FastMCP server's HTTP endpoints by validating JWT
|
| 5 |
icon: key
|
| 6 |
tag: NEW
|
| 7 |
---
|
| 8 |
|
| 9 |
import { VersionBadge } from "/snippets/version-badge.mdx"
|
| 10 |
|
| 11 |
-
<VersionBadge version="2.
|
|
|
|
| 12 |
<Tip>
|
| 13 |
Authentication and authorization are only relevant for HTTP-based transports.
|
| 14 |
</Tip>
|
| 15 |
|
| 16 |
-
<Note>
|
| 17 |
-
The [MCP specification](https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization) requires servers to implement full OAuth 2.1 authorization flows with dynamic client registration, server metadata discovery, and complete token endpoints. FastMCP's Bearer Token authentication provides a simpler, more practical alternative by directly validating pre-issued JWT tokens—ideal for service-to-service communication and programmatic environments where full OAuth flows may be impractical, and in accordance with how the MCP ecosystem is pragmatically evolving. However, please note that since it doesn't implement the full OAuth 2.1 flow, this implementation does not strictly comply with the MCP specification.
|
| 18 |
-
</Note>
|
| 19 |
-
|
| 20 |
Bearer Token authentication is a common way to secure HTTP-based APIs. In this model, the client sends a token (usually a JSON Web Token or JWT) in the `Authorization` header with the "Bearer" scheme. The server then validates this token to grant or deny access.
|
| 21 |
|
| 22 |
FastMCP supports Bearer Token authentication for its HTTP-based transports (`http` and `sse`), allowing you to protect your server from unauthorized access.
|
|
@@ -32,37 +29,29 @@ FastMCP uses **asymmetric encryption** for token validation, which provides a cl
|
|
| 32 |
|
| 33 |
This design allows you to integrate FastMCP servers into existing authentication infrastructures without compromising security boundaries.
|
| 34 |
|
| 35 |
-
##
|
| 36 |
|
| 37 |
-
|
| 38 |
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
</Warning>
|
| 42 |
-
|
| 43 |
-
### Basic Setup
|
| 44 |
|
| 45 |
-
|
|
|
|
| 46 |
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
```python {2, 10}
|
| 50 |
-
from fastmcp import FastMCP
|
| 51 |
-
from fastmcp.server.auth import BearerAuthProvider
|
| 52 |
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
algorithm="RS512",
|
| 57 |
-
audience="my-mcp-server"
|
| 58 |
-
)
|
| 59 |
|
| 60 |
-
mcp = FastMCP(name="My MCP Server", auth=auth)
|
| 61 |
-
```
|
| 62 |
|
| 63 |
### Configuration Parameters
|
| 64 |
|
| 65 |
-
<
|
|
|
|
|
|
|
| 66 |
<ParamField body="public_key" type="str">
|
| 67 |
RSA public key in PEM format for static key validation. Required if `jwks_uri` is not provided
|
| 68 |
</ParamField>
|
|
@@ -87,13 +76,66 @@ mcp = FastMCP(name="My MCP Server", auth=auth)
|
|
| 87 |
Global scopes required for all requests
|
| 88 |
</ParamField>
|
| 89 |
</Card>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
|
| 91 |
-
|
|
|
|
|
|
|
| 92 |
|
| 93 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
|
| 95 |
```python {12}
|
| 96 |
-
from fastmcp.server.auth import
|
| 97 |
import inspect
|
| 98 |
|
| 99 |
public_key_pem = inspect.cleandoc(
|
|
@@ -104,13 +146,13 @@ public_key_pem = inspect.cleandoc(
|
|
| 104 |
"""
|
| 105 |
)
|
| 106 |
|
| 107 |
-
auth =
|
| 108 |
```
|
| 109 |
|
| 110 |
-
#### JWKS URI
|
| 111 |
|
| 112 |
```python
|
| 113 |
-
|
| 114 |
jwks_uri="https://idp.example.com/.well-known/jwks.json"
|
| 115 |
)
|
| 116 |
```
|
|
@@ -119,6 +161,58 @@ provider = BearerAuthProvider(
|
|
| 119 |
JWKS is recommended for production as it supports automatic key rotation and multiple signing keys.
|
| 120 |
</Note>
|
| 121 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
## Generating Tokens
|
| 123 |
|
| 124 |
For development and testing, FastMCP provides the `RSAKeyPair` utility class to generate tokens without needing an external OAuth provider.
|
|
@@ -130,14 +224,13 @@ The `RSAKeyPair` utility is intended for development and testing only. For produ
|
|
| 130 |
|
| 131 |
```python
|
| 132 |
from fastmcp import FastMCP
|
| 133 |
-
from fastmcp.server.auth import
|
| 134 |
-
from fastmcp.server.auth.providers.bearer import RSAKeyPair
|
| 135 |
|
| 136 |
# Generate a new key pair
|
| 137 |
key_pair = RSAKeyPair.generate()
|
| 138 |
|
| 139 |
-
# Configure the auth
|
| 140 |
-
auth =
|
| 141 |
public_key=key_pair.public_key,
|
| 142 |
issuer="https://dev.example.com",
|
| 143 |
audience="my-dev-server"
|
|
@@ -191,6 +284,7 @@ The `create_token()` method accepts these parameters:
|
|
| 191 |
</Card>
|
| 192 |
|
| 193 |
|
|
|
|
| 194 |
## Accessing Token Claims
|
| 195 |
|
| 196 |
Once authenticated, your tools, resources, or prompts can access token information using the `get_access_token()` dependency function:
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Token Verification
|
| 3 |
+
sidebarTitle: Token Verification
|
| 4 |
+
description: Secure your FastMCP server's HTTP endpoints by validating JWT tokens.
|
| 5 |
icon: key
|
| 6 |
tag: NEW
|
| 7 |
---
|
| 8 |
|
| 9 |
import { VersionBadge } from "/snippets/version-badge.mdx"
|
| 10 |
|
| 11 |
+
<VersionBadge version="2.11.0" />
|
| 12 |
+
|
| 13 |
<Tip>
|
| 14 |
Authentication and authorization are only relevant for HTTP-based transports.
|
| 15 |
</Tip>
|
| 16 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
Bearer Token authentication is a common way to secure HTTP-based APIs. In this model, the client sends a token (usually a JSON Web Token or JWT) in the `Authorization` header with the "Bearer" scheme. The server then validates this token to grant or deny access.
|
| 18 |
|
| 19 |
FastMCP supports Bearer Token authentication for its HTTP-based transports (`http` and `sse`), allowing you to protect your server from unauthorized access.
|
|
|
|
| 29 |
|
| 30 |
This design allows you to integrate FastMCP servers into existing authentication infrastructures without compromising security boundaries.
|
| 31 |
|
| 32 |
+
## Token Verification Approaches
|
| 33 |
|
| 34 |
+
FastMCP provides three token verification approaches:
|
| 35 |
|
| 36 |
+
### JWTVerifier
|
| 37 |
+
Validates JWT tokens using public key cryptography. Use when you have JWT tokens issued by an external identity provider (Auth0, Okta, Keycloak, etc.) and want self-contained validation without network calls.
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
+
### IntrospectionTokenVerifier
|
| 40 |
+
Validates tokens by calling a remote OAuth 2.0 authorization server's introspection endpoint (RFC 7662). Use when your authorization server is separate from your FastMCP server, you're using opaque tokens, or you need real-time token revocation.
|
| 41 |
|
| 42 |
+
### StaticTokenVerifier
|
| 43 |
+
Validates tokens against a predefined dictionary. Use for development and testing only - never in production.
|
|
|
|
|
|
|
|
|
|
| 44 |
|
| 45 |
+
<Warning>
|
| 46 |
+
These verifiers validate tokens; they do **not** issue them (or implement any part of an OAuth flow). You'll need to generate tokens separately, either using FastMCP utilities or an external Identity Provider (IdP) or OAuth 2.1 Authorization Server.
|
| 47 |
+
</Warning>
|
|
|
|
|
|
|
|
|
|
| 48 |
|
|
|
|
|
|
|
| 49 |
|
| 50 |
### Configuration Parameters
|
| 51 |
|
| 52 |
+
<Tabs>
|
| 53 |
+
<Tab title="JWTVerifier">
|
| 54 |
+
<Card icon="code" title="JWTVerifier Configuration">
|
| 55 |
<ParamField body="public_key" type="str">
|
| 56 |
RSA public key in PEM format for static key validation. Required if `jwks_uri` is not provided
|
| 57 |
</ParamField>
|
|
|
|
| 76 |
Global scopes required for all requests
|
| 77 |
</ParamField>
|
| 78 |
</Card>
|
| 79 |
+
</Tab>
|
| 80 |
+
|
| 81 |
+
<Tab title="IntrospectionTokenVerifier">
|
| 82 |
+
<Card icon="code" title="IntrospectionTokenVerifier Configuration">
|
| 83 |
+
<ParamField body="introspection_endpoint" type="str">
|
| 84 |
+
OAuth 2.0 Token Introspection endpoint URL (RFC 7662)
|
| 85 |
+
</ParamField>
|
| 86 |
+
|
| 87 |
+
<ParamField body="client_id" type="str">
|
| 88 |
+
Resource server client ID for introspection authentication
|
| 89 |
+
</ParamField>
|
| 90 |
+
|
| 91 |
+
<ParamField body="client_secret" type="str">
|
| 92 |
+
Resource server client secret for introspection authentication
|
| 93 |
+
</ParamField>
|
| 94 |
+
|
| 95 |
+
<ParamField body="required_scopes" type="list[str] | None">
|
| 96 |
+
Global scopes required for all requests
|
| 97 |
+
</ParamField>
|
| 98 |
+
</Card>
|
| 99 |
+
</Tab>
|
| 100 |
+
|
| 101 |
+
<Tab title="StaticTokenVerifier">
|
| 102 |
+
<Card icon="code" title="StaticTokenVerifier Configuration">
|
| 103 |
+
<ParamField body="valid_tokens" type="dict[str, dict]">
|
| 104 |
+
Mapping of valid tokens to their claims. Each token maps to a dictionary containing token metadata like `sub`, `scope`, etc.
|
| 105 |
+
</ParamField>
|
| 106 |
+
|
| 107 |
+
<ParamField body="required_scopes" type="list[str] | None">
|
| 108 |
+
Global scopes required for all requests
|
| 109 |
+
</ParamField>
|
| 110 |
+
</Card>
|
| 111 |
+
</Tab>
|
| 112 |
+
</Tabs>
|
| 113 |
+
|
| 114 |
+
## JWT Verification
|
| 115 |
+
|
| 116 |
+
The `JWTVerifier` validates JWT tokens using public key cryptography. Use this when you have JWT tokens issued by an external identity provider and want self-contained validation without network calls.
|
| 117 |
|
| 118 |
+
```python
|
| 119 |
+
from fastmcp import FastMCP
|
| 120 |
+
from fastmcp.server.auth.verifiers import JWTVerifier
|
| 121 |
|
| 122 |
+
verifier = JWTVerifier(
|
| 123 |
+
jwks_uri="https://my-identity-provider.com/.well-known/jwks.json",
|
| 124 |
+
issuer="https://my-identity-provider.com/",
|
| 125 |
+
audience="my-mcp-server"
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
mcp = FastMCP(name="My MCP Server", auth=verifier)
|
| 129 |
+
```
|
| 130 |
+
|
| 131 |
+
### Public Key Configuration
|
| 132 |
+
|
| 133 |
+
#### Using a Static Public Key
|
| 134 |
+
|
| 135 |
+
If you have a public key in PEM format, you can provide it to the `JWTVerifier` as a string.
|
| 136 |
|
| 137 |
```python {12}
|
| 138 |
+
from fastmcp.server.auth.verifiers import JWTVerifier
|
| 139 |
import inspect
|
| 140 |
|
| 141 |
public_key_pem = inspect.cleandoc(
|
|
|
|
| 146 |
"""
|
| 147 |
)
|
| 148 |
|
| 149 |
+
auth = JWTVerifier(public_key=public_key_pem)
|
| 150 |
```
|
| 151 |
|
| 152 |
+
#### Using JWKS URI
|
| 153 |
|
| 154 |
```python
|
| 155 |
+
verifier = JWTVerifier(
|
| 156 |
jwks_uri="https://idp.example.com/.well-known/jwks.json"
|
| 157 |
)
|
| 158 |
```
|
|
|
|
| 161 |
JWKS is recommended for production as it supports automatic key rotation and multiple signing keys.
|
| 162 |
</Note>
|
| 163 |
|
| 164 |
+
## OAuth 2.0 Token Introspection
|
| 165 |
+
|
| 166 |
+
The `IntrospectionTokenVerifier` validates tokens by calling an OAuth 2.0 authorization server's introspection endpoint (RFC 7662). This is useful when your authorization server is separate from your FastMCP server, you're using opaque tokens, or you need real-time token validation with immediate revocation support.
|
| 167 |
+
|
| 168 |
+
```python
|
| 169 |
+
from fastmcp.server.auth.verifiers import IntrospectionTokenVerifier
|
| 170 |
+
|
| 171 |
+
verifier = IntrospectionTokenVerifier(
|
| 172 |
+
introspection_endpoint="https://auth.company.com/oauth/introspect",
|
| 173 |
+
server_url="https://mcp.company.com", # This server's URL
|
| 174 |
+
client_id="mcp-resource-server",
|
| 175 |
+
client_secret="your-secret",
|
| 176 |
+
required_scopes=["mcp:access"]
|
| 177 |
+
)
|
| 178 |
+
|
| 179 |
+
mcp = FastMCP(name="MCP Server", auth=verifier)
|
| 180 |
+
```
|
| 181 |
+
|
| 182 |
+
For each request, the verifier makes an HTTP call to the introspection endpoint to check if the token is valid and active. This provides real-time validation but requires network connectivity.
|
| 183 |
+
|
| 184 |
+
## Static Token Verification
|
| 185 |
+
|
| 186 |
+
The `StaticTokenVerifier` validates tokens against a predefined dictionary of token strings and claims. Use this for development and testing when you need predictable tokens without setting up a real OAuth server.
|
| 187 |
+
|
| 188 |
+
```python
|
| 189 |
+
from fastmcp.server.auth.verifiers import StaticTokenVerifier
|
| 190 |
+
|
| 191 |
+
verifier = StaticTokenVerifier(
|
| 192 |
+
tokens={
|
| 193 |
+
"dev-token-123": {
|
| 194 |
+
"client_id": "dev-user",
|
| 195 |
+
"scopes": ["read", "write"],
|
| 196 |
+
"sub": "developer@example.com"
|
| 197 |
+
},
|
| 198 |
+
"readonly-token": {
|
| 199 |
+
"client_id": "readonly-user",
|
| 200 |
+
"scopes": ["read"],
|
| 201 |
+
"expires_at": 1735689600 # Optional expiration
|
| 202 |
+
}
|
| 203 |
+
},
|
| 204 |
+
required_scopes=["read"]
|
| 205 |
+
)
|
| 206 |
+
|
| 207 |
+
mcp = FastMCP(name="Development Server", auth=verifier)
|
| 208 |
+
```
|
| 209 |
+
|
| 210 |
+
Token claims can include `client_id` (required), `scopes`, `sub`, `expires_at`, and any custom metadata your application needs.
|
| 211 |
+
|
| 212 |
+
<Warning>
|
| 213 |
+
Never use StaticTokenVerifier in production - tokens are stored in plain text.
|
| 214 |
+
</Warning>
|
| 215 |
+
|
| 216 |
## Generating Tokens
|
| 217 |
|
| 218 |
For development and testing, FastMCP provides the `RSAKeyPair` utility class to generate tokens without needing an external OAuth provider.
|
|
|
|
| 224 |
|
| 225 |
```python
|
| 226 |
from fastmcp import FastMCP
|
| 227 |
+
from fastmcp.server.auth.verifiers import JWTVerifier, RSAKeyPair
|
|
|
|
| 228 |
|
| 229 |
# Generate a new key pair
|
| 230 |
key_pair = RSAKeyPair.generate()
|
| 231 |
|
| 232 |
+
# Configure the auth verifier with the public key
|
| 233 |
+
auth = JWTVerifier(
|
| 234 |
public_key=key_pair.public_key,
|
| 235 |
issuer="https://dev.example.com",
|
| 236 |
audience="my-dev-server"
|
|
|
|
| 284 |
</Card>
|
| 285 |
|
| 286 |
|
| 287 |
+
|
| 288 |
## Accessing Token Claims
|
| 289 |
|
| 290 |
Once authenticated, your tools, resources, or prompts can access token information using the `get_access_token()` dependency function:
|
docs/servers/server.mdx
CHANGED
|
@@ -40,6 +40,10 @@ The `FastMCP` constructor accepts several arguments:
|
|
| 40 |
Description of how to interact with this server. These instructions help clients understand the server's purpose and available functionality
|
| 41 |
</ParamField>
|
| 42 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
<ParamField body="lifespan" type="AsyncContextManager | None">
|
| 44 |
An async context manager function for server startup and shutdown logic
|
| 45 |
</ParamField>
|
|
|
|
| 40 |
Description of how to interact with this server. These instructions help clients understand the server's purpose and available functionality
|
| 41 |
</ParamField>
|
| 42 |
|
| 43 |
+
<ParamField body="auth" type="OAuthProvider | TokenVerifier | None">
|
| 44 |
+
Authentication provider for securing HTTP-based transports. See [Bearer Token Authentication](/servers/auth/bearer) for configuration options
|
| 45 |
+
</ParamField>
|
| 46 |
+
|
| 47 |
<ParamField body="lifespan" type="AsyncContextManager | None">
|
| 48 |
An async context manager function for server startup and shutdown logic
|
| 49 |
</ParamField>
|
src/fastmcp/contrib/component_manager/example.py
CHANGED
|
@@ -1,10 +1,10 @@
|
|
| 1 |
from fastmcp import FastMCP
|
| 2 |
from fastmcp.contrib.component_manager import set_up_component_manager
|
| 3 |
-
from fastmcp.server.auth.
|
| 4 |
|
| 5 |
key_pair = RSAKeyPair.generate()
|
| 6 |
|
| 7 |
-
auth =
|
| 8 |
public_key=key_pair.public_key,
|
| 9 |
issuer="https://dev.example.com",
|
| 10 |
audience="my-dev-server",
|
|
|
|
| 1 |
from fastmcp import FastMCP
|
| 2 |
from fastmcp.contrib.component_manager import set_up_component_manager
|
| 3 |
+
from fastmcp.server.auth.verifiers import JWTVerifier, RSAKeyPair
|
| 4 |
|
| 5 |
key_pair = RSAKeyPair.generate()
|
| 6 |
|
| 7 |
+
auth = JWTVerifier(
|
| 8 |
public_key=key_pair.public_key,
|
| 9 |
issuer="https://dev.example.com",
|
| 10 |
audience="my-dev-server",
|
src/fastmcp/server/auth/__init__.py
CHANGED
|
@@ -1,4 +1,20 @@
|
|
| 1 |
-
from .
|
|
|
|
| 2 |
|
| 3 |
|
| 4 |
-
__all__ = [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .auth import OAuthProvider, TokenVerifier
|
| 2 |
+
from .verifiers import IntrospectionTokenVerifier, JWTVerifier, StaticTokenVerifier
|
| 3 |
|
| 4 |
|
| 5 |
+
__all__ = [
|
| 6 |
+
"OAuthProvider",
|
| 7 |
+
"TokenVerifier",
|
| 8 |
+
"IntrospectionTokenVerifier",
|
| 9 |
+
"JWTVerifier",
|
| 10 |
+
"StaticTokenVerifier",
|
| 11 |
+
]
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def __getattr__(name: str):
|
| 15 |
+
# Defer import because it raises a deprecation warning
|
| 16 |
+
if name == "BearerAuthProvider":
|
| 17 |
+
from .providers.bearer import BearerAuthProvider
|
| 18 |
+
|
| 19 |
+
return BearerAuthProvider
|
| 20 |
+
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
|
src/fastmcp/server/auth/auth.py
CHANGED
|
@@ -4,6 +4,9 @@ from mcp.server.auth.provider import (
|
|
| 4 |
OAuthAuthorizationServerProvider,
|
| 5 |
RefreshToken,
|
| 6 |
)
|
|
|
|
|
|
|
|
|
|
| 7 |
from mcp.server.auth.settings import (
|
| 8 |
ClientRegistrationOptions,
|
| 9 |
RevocationOptions,
|
|
@@ -11,6 +14,35 @@ from mcp.server.auth.settings import (
|
|
| 11 |
from pydantic import AnyHttpUrl
|
| 12 |
|
| 13 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
class OAuthProvider(
|
| 15 |
OAuthAuthorizationServerProvider[AuthorizationCode, RefreshToken, AccessToken]
|
| 16 |
):
|
|
@@ -44,21 +76,3 @@ class OAuthProvider(
|
|
| 44 |
self.client_registration_options = client_registration_options
|
| 45 |
self.revocation_options = revocation_options
|
| 46 |
self.required_scopes = required_scopes
|
| 47 |
-
self.resource_server_url = (
|
| 48 |
-
AnyHttpUrl(resource_server_url) if resource_server_url else None
|
| 49 |
-
)
|
| 50 |
-
|
| 51 |
-
async def verify_token(self, token: str) -> AccessToken | None:
|
| 52 |
-
"""
|
| 53 |
-
Verify a bearer token and return access info if valid.
|
| 54 |
-
|
| 55 |
-
This method implements the TokenVerifier protocol by delegating
|
| 56 |
-
to our existing load_access_token method.
|
| 57 |
-
|
| 58 |
-
Args:
|
| 59 |
-
token: The token string to validate
|
| 60 |
-
|
| 61 |
-
Returns:
|
| 62 |
-
AccessToken object if valid, None if invalid or expired
|
| 63 |
-
"""
|
| 64 |
-
return await self.load_access_token(token)
|
|
|
|
| 4 |
OAuthAuthorizationServerProvider,
|
| 5 |
RefreshToken,
|
| 6 |
)
|
| 7 |
+
from mcp.server.auth.provider import (
|
| 8 |
+
TokenVerifier as TokenVerifierProtocol,
|
| 9 |
+
)
|
| 10 |
from mcp.server.auth.settings import (
|
| 11 |
ClientRegistrationOptions,
|
| 12 |
RevocationOptions,
|
|
|
|
| 14 |
from pydantic import AnyHttpUrl
|
| 15 |
|
| 16 |
|
| 17 |
+
class TokenVerifier(TokenVerifierProtocol):
|
| 18 |
+
"""Base class for token verifiers (Resource Servers)."""
|
| 19 |
+
|
| 20 |
+
def __init__(
|
| 21 |
+
self,
|
| 22 |
+
resource_server_url: AnyHttpUrl | str | None = None,
|
| 23 |
+
required_scopes: list[str] | None = None,
|
| 24 |
+
):
|
| 25 |
+
"""
|
| 26 |
+
Initialize the token verifier.
|
| 27 |
+
|
| 28 |
+
Args:
|
| 29 |
+
resource_server_url: The URL of this resource server (for RFC 8707 resource indicators)
|
| 30 |
+
required_scopes: Scopes that are required for all requests
|
| 31 |
+
"""
|
| 32 |
+
self.resource_server_url: AnyHttpUrl | None
|
| 33 |
+
if resource_server_url is None:
|
| 34 |
+
self.resource_server_url = None
|
| 35 |
+
elif isinstance(resource_server_url, str):
|
| 36 |
+
self.resource_server_url = AnyHttpUrl(resource_server_url)
|
| 37 |
+
else:
|
| 38 |
+
self.resource_server_url = resource_server_url
|
| 39 |
+
self.required_scopes = required_scopes or []
|
| 40 |
+
|
| 41 |
+
async def verify_token(self, token: str) -> AccessToken | None:
|
| 42 |
+
"""Verify a bearer token and return access info if valid."""
|
| 43 |
+
raise NotImplementedError("Subclasses must implement verify_token")
|
| 44 |
+
|
| 45 |
+
|
| 46 |
class OAuthProvider(
|
| 47 |
OAuthAuthorizationServerProvider[AuthorizationCode, RefreshToken, AccessToken]
|
| 48 |
):
|
|
|
|
| 76 |
self.client_registration_options = client_registration_options
|
| 77 |
self.revocation_options = revocation_options
|
| 78 |
self.required_scopes = required_scopes
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/fastmcp/server/auth/providers/bearer.py
CHANGED
|
@@ -1,482 +1,25 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
from
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
RevocationOptions,
|
| 27 |
-
)
|
| 28 |
-
from fastmcp.utilities.logging import get_logger
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
class JWKData(TypedDict, total=False):
|
| 32 |
-
"""JSON Web Key data structure."""
|
| 33 |
-
|
| 34 |
-
kty: str # Key type (e.g., "RSA") - required
|
| 35 |
-
kid: str # Key ID (optional but recommended)
|
| 36 |
-
use: str # Usage (e.g., "sig")
|
| 37 |
-
alg: str # Algorithm (e.g., "RS256")
|
| 38 |
-
n: str # Modulus (for RSA keys)
|
| 39 |
-
e: str # Exponent (for RSA keys)
|
| 40 |
-
x5c: list[str] # X.509 certificate chain (for JWKs)
|
| 41 |
-
x5t: str # X.509 certificate thumbprint (for JWKs)
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
class JWKSData(TypedDict):
|
| 45 |
-
"""JSON Web Key Set data structure."""
|
| 46 |
-
|
| 47 |
-
keys: list[JWKData]
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
@dataclass(frozen=True, kw_only=True, repr=False)
|
| 51 |
-
class RSAKeyPair:
|
| 52 |
-
private_key: SecretStr
|
| 53 |
-
public_key: str
|
| 54 |
-
|
| 55 |
-
@classmethod
|
| 56 |
-
def generate(cls) -> "RSAKeyPair":
|
| 57 |
-
"""
|
| 58 |
-
Generate an RSA key pair for testing.
|
| 59 |
-
|
| 60 |
-
Returns:
|
| 61 |
-
tuple: (private_key_pem, public_key_pem)
|
| 62 |
-
"""
|
| 63 |
-
# Generate private key
|
| 64 |
-
private_key = rsa.generate_private_key(
|
| 65 |
-
public_exponent=65537,
|
| 66 |
-
key_size=2048,
|
| 67 |
-
)
|
| 68 |
-
|
| 69 |
-
# Get public key
|
| 70 |
-
public_key = private_key.public_key()
|
| 71 |
-
|
| 72 |
-
# Serialize private key to PEM format
|
| 73 |
-
private_pem = private_key.private_bytes(
|
| 74 |
-
encoding=serialization.Encoding.PEM,
|
| 75 |
-
format=serialization.PrivateFormat.PKCS8,
|
| 76 |
-
encryption_algorithm=serialization.NoEncryption(),
|
| 77 |
-
).decode("utf-8")
|
| 78 |
-
|
| 79 |
-
# Serialize public key to PEM format
|
| 80 |
-
public_pem = public_key.public_bytes(
|
| 81 |
-
encoding=serialization.Encoding.PEM,
|
| 82 |
-
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
| 83 |
-
).decode("utf-8")
|
| 84 |
-
|
| 85 |
-
return cls(
|
| 86 |
-
private_key=SecretStr(private_pem),
|
| 87 |
-
public_key=public_pem,
|
| 88 |
-
)
|
| 89 |
-
|
| 90 |
-
def create_token(
|
| 91 |
-
self,
|
| 92 |
-
subject: str = "fastmcp-user",
|
| 93 |
-
issuer: str = "https://fastmcp.example.com",
|
| 94 |
-
audience: str | list[str] | None = None,
|
| 95 |
-
scopes: list[str] | None = None,
|
| 96 |
-
expires_in_seconds: int = 3600,
|
| 97 |
-
additional_claims: dict[str, Any] | None = None,
|
| 98 |
-
kid: str | None = None,
|
| 99 |
-
) -> str:
|
| 100 |
-
"""
|
| 101 |
-
Generate a test JWT token for testing purposes.
|
| 102 |
-
|
| 103 |
-
Args:
|
| 104 |
-
private_key_pem: RSA private key in PEM format
|
| 105 |
-
subject: Subject claim (usually user ID)
|
| 106 |
-
issuer: Issuer claim
|
| 107 |
-
audience: Audience claim - can be a string or list of strings (optional)
|
| 108 |
-
scopes: List of scopes to include
|
| 109 |
-
expires_in_seconds: Token expiration time in seconds
|
| 110 |
-
additional_claims: Any additional claims to include
|
| 111 |
-
kid: Key ID for JWKS lookup (optional)
|
| 112 |
-
|
| 113 |
-
Returns:
|
| 114 |
-
Signed JWT token string
|
| 115 |
-
"""
|
| 116 |
-
# TODO : Add support for configurable algorithms
|
| 117 |
-
jwt = JsonWebToken(["RS256"])
|
| 118 |
-
|
| 119 |
-
now = int(time.time())
|
| 120 |
-
|
| 121 |
-
# Build payload
|
| 122 |
-
payload = {
|
| 123 |
-
"iss": issuer,
|
| 124 |
-
"sub": subject,
|
| 125 |
-
"iat": now,
|
| 126 |
-
"exp": now + expires_in_seconds,
|
| 127 |
-
}
|
| 128 |
-
|
| 129 |
-
if audience:
|
| 130 |
-
payload["aud"] = audience
|
| 131 |
-
|
| 132 |
-
if scopes:
|
| 133 |
-
payload["scope"] = " ".join(scopes)
|
| 134 |
-
|
| 135 |
-
if additional_claims:
|
| 136 |
-
payload.update(additional_claims)
|
| 137 |
-
|
| 138 |
-
# Create header
|
| 139 |
-
header = {"alg": "RS256"}
|
| 140 |
-
if kid:
|
| 141 |
-
header["kid"] = kid
|
| 142 |
-
|
| 143 |
-
# Sign and return token
|
| 144 |
-
token_bytes = jwt.encode(
|
| 145 |
-
header,
|
| 146 |
-
payload,
|
| 147 |
-
key=self.private_key.get_secret_value(),
|
| 148 |
-
)
|
| 149 |
-
return token_bytes.decode("utf-8")
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
class BearerAuthProvider(OAuthProvider):
|
| 153 |
-
"""
|
| 154 |
-
Simple JWT Bearer Token validator for hosted MCP servers.
|
| 155 |
-
Uses RS256 asymmetric encryption by default but supports all JWA algorithms. Supports either static public key
|
| 156 |
-
or JWKS URI for key rotation.
|
| 157 |
-
|
| 158 |
-
Note that this provider DOES NOT permit client registration or revocation, or any OAuth flows.
|
| 159 |
-
It is intended to be used with a control plane that manages clients and tokens.
|
| 160 |
-
"""
|
| 161 |
-
|
| 162 |
-
def __init__(
|
| 163 |
-
self,
|
| 164 |
-
public_key: str | None = None,
|
| 165 |
-
jwks_uri: str | None = None,
|
| 166 |
-
issuer: str | None = None,
|
| 167 |
-
algorithm: str | None = None,
|
| 168 |
-
audience: str | list[str] | None = None,
|
| 169 |
-
required_scopes: list[str] | None = None,
|
| 170 |
-
resource_server: str | None = None,
|
| 171 |
-
):
|
| 172 |
-
"""
|
| 173 |
-
Initialize the provider. Either public_key or jwks_uri must be provided.
|
| 174 |
-
|
| 175 |
-
Args:
|
| 176 |
-
public_key: RSA public key in PEM format (for static key)
|
| 177 |
-
jwks_uri: URI to fetch keys from (for key rotation)
|
| 178 |
-
issuer: Expected issuer claim (optional)
|
| 179 |
-
algorithm: Algorithm to use for verification (optional, defaults to RS256)
|
| 180 |
-
audience: Expected audience claim - can be a string or list of strings (optional)
|
| 181 |
-
required_scopes: List of required scopes for access (optional)
|
| 182 |
-
"""
|
| 183 |
-
if not (public_key or jwks_uri):
|
| 184 |
-
raise ValueError("Either public_key or jwks_uri must be provided")
|
| 185 |
-
if public_key and jwks_uri:
|
| 186 |
-
raise ValueError("Provide either public_key or jwks_uri, not both")
|
| 187 |
-
|
| 188 |
-
if not algorithm:
|
| 189 |
-
algorithm = "RS256"
|
| 190 |
-
if algorithm not in {
|
| 191 |
-
"HS256",
|
| 192 |
-
"HS384",
|
| 193 |
-
"HS512",
|
| 194 |
-
"RS256",
|
| 195 |
-
"RS384",
|
| 196 |
-
"RS512",
|
| 197 |
-
"ES256",
|
| 198 |
-
"ES384",
|
| 199 |
-
"ES512",
|
| 200 |
-
"PS256",
|
| 201 |
-
"PS384",
|
| 202 |
-
"PS512",
|
| 203 |
-
}:
|
| 204 |
-
raise ValueError(f"Unsupported algorithm: {algorithm}.")
|
| 205 |
-
|
| 206 |
-
# Only pass issuer to parent if it's a valid URL, otherwise use default
|
| 207 |
-
# This allows the issuer claim validation to work with string issuers per RFC 7519
|
| 208 |
-
try:
|
| 209 |
-
issuer_url = AnyHttpUrl(issuer) if issuer else "https://fastmcp.example.com"
|
| 210 |
-
except ValidationError:
|
| 211 |
-
# Issuer is not a valid URL, use default for parent class
|
| 212 |
-
issuer_url = "https://fastmcp.example.com"
|
| 213 |
-
|
| 214 |
-
try:
|
| 215 |
-
resource_server_url = (
|
| 216 |
-
AnyHttpUrl(resource_server) if resource_server else None
|
| 217 |
-
)
|
| 218 |
-
except ValidationError:
|
| 219 |
-
resource_server_url = None
|
| 220 |
-
|
| 221 |
-
super().__init__(
|
| 222 |
-
issuer_url=issuer_url,
|
| 223 |
-
client_registration_options=ClientRegistrationOptions(enabled=False),
|
| 224 |
-
revocation_options=RevocationOptions(enabled=False),
|
| 225 |
-
required_scopes=required_scopes,
|
| 226 |
-
resource_server_url=resource_server_url,
|
| 227 |
-
)
|
| 228 |
-
|
| 229 |
-
self.algorithm = algorithm
|
| 230 |
-
self.issuer = issuer
|
| 231 |
-
self.audience = audience
|
| 232 |
-
self.public_key = public_key
|
| 233 |
-
self.jwks_uri = jwks_uri
|
| 234 |
-
self.jwt = JsonWebToken([self.algorithm]) # Use RS256 by default
|
| 235 |
-
self.logger = get_logger(__name__)
|
| 236 |
-
|
| 237 |
-
# Simple JWKS cache
|
| 238 |
-
self._jwks_cache: dict[str, str] = {}
|
| 239 |
-
self._jwks_cache_time: float = 0
|
| 240 |
-
self._cache_ttl = 3600 # 1 hour
|
| 241 |
-
|
| 242 |
-
async def _get_verification_key(self, token: str) -> str:
|
| 243 |
-
"""Get the verification key for the token."""
|
| 244 |
-
if self.public_key:
|
| 245 |
-
return self.public_key
|
| 246 |
-
|
| 247 |
-
# Extract kid from token header for JWKS lookup
|
| 248 |
-
try:
|
| 249 |
-
import base64
|
| 250 |
-
import json
|
| 251 |
-
|
| 252 |
-
header_b64 = token.split(".")[0]
|
| 253 |
-
header_b64 += "=" * (4 - len(header_b64) % 4) # Add padding
|
| 254 |
-
header = json.loads(base64.urlsafe_b64decode(header_b64))
|
| 255 |
-
kid = header.get("kid")
|
| 256 |
-
|
| 257 |
-
return await self._get_jwks_key(kid)
|
| 258 |
-
|
| 259 |
-
except Exception as e:
|
| 260 |
-
raise ValueError(f"Failed to extract key ID from token: {e}")
|
| 261 |
-
|
| 262 |
-
async def _get_jwks_key(self, kid: str | None) -> str:
|
| 263 |
-
"""Fetch key from JWKS with simple caching."""
|
| 264 |
-
if not self.jwks_uri:
|
| 265 |
-
raise ValueError("JWKS URI not configured")
|
| 266 |
-
|
| 267 |
-
current_time = time.time()
|
| 268 |
-
|
| 269 |
-
# Check cache first
|
| 270 |
-
if current_time - self._jwks_cache_time < self._cache_ttl:
|
| 271 |
-
if kid and kid in self._jwks_cache:
|
| 272 |
-
return self._jwks_cache[kid]
|
| 273 |
-
elif not kid and len(self._jwks_cache) == 1:
|
| 274 |
-
# If no kid but only one key cached, use it
|
| 275 |
-
return next(iter(self._jwks_cache.values()))
|
| 276 |
-
|
| 277 |
-
# Fetch JWKS
|
| 278 |
-
try:
|
| 279 |
-
async with httpx.AsyncClient() as client:
|
| 280 |
-
response = await client.get(self.jwks_uri)
|
| 281 |
-
response.raise_for_status()
|
| 282 |
-
jwks_data = response.json()
|
| 283 |
-
|
| 284 |
-
# Cache all keys
|
| 285 |
-
self._jwks_cache = {}
|
| 286 |
-
for key_data in jwks_data.get("keys", []):
|
| 287 |
-
key_kid = key_data.get("kid")
|
| 288 |
-
jwk = JsonWebKey.import_key(key_data)
|
| 289 |
-
public_key = jwk.get_public_key() # type: ignore
|
| 290 |
-
|
| 291 |
-
if key_kid:
|
| 292 |
-
self._jwks_cache[key_kid] = public_key
|
| 293 |
-
else:
|
| 294 |
-
# Key without kid - use a default identifier
|
| 295 |
-
self._jwks_cache["_default"] = public_key
|
| 296 |
-
|
| 297 |
-
self._jwks_cache_time = current_time
|
| 298 |
-
|
| 299 |
-
# Select the appropriate key
|
| 300 |
-
if kid:
|
| 301 |
-
if kid not in self._jwks_cache:
|
| 302 |
-
self.logger.debug(
|
| 303 |
-
"JWKS key lookup failed: key ID '%s' not found", kid
|
| 304 |
-
)
|
| 305 |
-
raise ValueError(f"Key ID '{kid}' not found in JWKS")
|
| 306 |
-
return self._jwks_cache[kid]
|
| 307 |
-
else:
|
| 308 |
-
# No kid in token - only allow if there's exactly one key
|
| 309 |
-
if len(self._jwks_cache) == 1:
|
| 310 |
-
return next(iter(self._jwks_cache.values()))
|
| 311 |
-
elif len(self._jwks_cache) > 1:
|
| 312 |
-
raise ValueError(
|
| 313 |
-
"Multiple keys in JWKS but no key ID (kid) in token"
|
| 314 |
-
)
|
| 315 |
-
else:
|
| 316 |
-
raise ValueError("No keys found in JWKS")
|
| 317 |
-
|
| 318 |
-
except Exception as e:
|
| 319 |
-
self.logger.debug("JWKS fetch failed: %s", str(e))
|
| 320 |
-
raise ValueError(f"Failed to fetch JWKS: {e}")
|
| 321 |
-
|
| 322 |
-
async def load_access_token(self, token: str) -> AccessToken | None:
|
| 323 |
-
"""
|
| 324 |
-
Validates the provided JWT bearer token.
|
| 325 |
-
|
| 326 |
-
Args:
|
| 327 |
-
token: The JWT token string to validate
|
| 328 |
-
|
| 329 |
-
Returns:
|
| 330 |
-
AccessToken object if valid, None if invalid or expired
|
| 331 |
-
"""
|
| 332 |
-
try:
|
| 333 |
-
# Get verification key (static or from JWKS)
|
| 334 |
-
verification_key = await self._get_verification_key(token)
|
| 335 |
-
|
| 336 |
-
# Decode and verify the JWT token
|
| 337 |
-
claims = self.jwt.decode(token, verification_key)
|
| 338 |
-
|
| 339 |
-
# Extract client ID early for logging
|
| 340 |
-
client_id = claims.get("client_id") or claims.get("sub") or "unknown"
|
| 341 |
-
|
| 342 |
-
# Validate expiration
|
| 343 |
-
exp = claims.get("exp")
|
| 344 |
-
if exp and exp < time.time():
|
| 345 |
-
self.logger.debug(
|
| 346 |
-
"Token validation failed: expired token for client %s", client_id
|
| 347 |
-
)
|
| 348 |
-
self.logger.info("Bearer token rejected for client %s", client_id)
|
| 349 |
-
return None
|
| 350 |
-
|
| 351 |
-
# Validate issuer - note we use issuer instead of issuer_url here because
|
| 352 |
-
# issuer is optional, allowing users to make this check optional
|
| 353 |
-
if self.issuer:
|
| 354 |
-
if claims.get("iss") != self.issuer:
|
| 355 |
-
self.logger.debug(
|
| 356 |
-
"Token validation failed: issuer mismatch for client %s",
|
| 357 |
-
client_id,
|
| 358 |
-
)
|
| 359 |
-
self.logger.info("Bearer token rejected for client %s", client_id)
|
| 360 |
-
return None
|
| 361 |
-
|
| 362 |
-
# Validate audience if configured
|
| 363 |
-
if self.audience:
|
| 364 |
-
aud = claims.get("aud")
|
| 365 |
-
|
| 366 |
-
# Handle different combinations of audience types
|
| 367 |
-
audience_valid = False
|
| 368 |
-
if isinstance(self.audience, list):
|
| 369 |
-
# self.audience is a list - check if any expected audience is present
|
| 370 |
-
if isinstance(aud, list):
|
| 371 |
-
# Both are lists - check for intersection
|
| 372 |
-
audience_valid = any(
|
| 373 |
-
expected in aud for expected in self.audience
|
| 374 |
-
)
|
| 375 |
-
else:
|
| 376 |
-
# aud is a string - check if it's in our expected list
|
| 377 |
-
audience_valid = aud in self.audience
|
| 378 |
-
else:
|
| 379 |
-
# self.audience is a string - use original logic
|
| 380 |
-
if isinstance(aud, list):
|
| 381 |
-
audience_valid = self.audience in aud
|
| 382 |
-
else:
|
| 383 |
-
audience_valid = aud == self.audience
|
| 384 |
-
|
| 385 |
-
if not audience_valid:
|
| 386 |
-
self.logger.debug(
|
| 387 |
-
"Token validation failed: audience mismatch for client %s",
|
| 388 |
-
client_id,
|
| 389 |
-
)
|
| 390 |
-
self.logger.info("Bearer token rejected for client %s", client_id)
|
| 391 |
-
return None
|
| 392 |
-
|
| 393 |
-
# Extract scopes
|
| 394 |
-
scopes = self._extract_scopes(claims)
|
| 395 |
-
|
| 396 |
-
return AccessToken(
|
| 397 |
-
token=token,
|
| 398 |
-
client_id=str(client_id),
|
| 399 |
-
scopes=scopes,
|
| 400 |
-
expires_at=int(exp) if exp else None,
|
| 401 |
-
)
|
| 402 |
-
|
| 403 |
-
except JoseError:
|
| 404 |
-
self.logger.debug("Token validation failed: JWT signature/format invalid")
|
| 405 |
-
return None
|
| 406 |
-
except Exception as e:
|
| 407 |
-
self.logger.debug("Token validation failed: %s", str(e))
|
| 408 |
-
return None
|
| 409 |
-
|
| 410 |
-
def _extract_scopes(self, claims: dict[str, Any]) -> list[str]:
|
| 411 |
-
"""
|
| 412 |
-
Extract scopes from JWT claims. Supports both 'scope' and 'scp'
|
| 413 |
-
claims.
|
| 414 |
-
|
| 415 |
-
Checks the `scope` claim first (standard OAuth2 claim), then the `scp`
|
| 416 |
-
claim (used by some Identity Providers).
|
| 417 |
-
"""
|
| 418 |
-
|
| 419 |
-
for claim in ["scope", "scp"]:
|
| 420 |
-
if claim in claims:
|
| 421 |
-
if isinstance(claims[claim], str):
|
| 422 |
-
return claims[claim].split()
|
| 423 |
-
elif isinstance(claims[claim], list):
|
| 424 |
-
return claims[claim]
|
| 425 |
-
|
| 426 |
-
return []
|
| 427 |
-
|
| 428 |
-
async def verify_token(self, token: str) -> AccessToken | None:
|
| 429 |
-
"""
|
| 430 |
-
Verify a bearer token and return access info if valid.
|
| 431 |
-
|
| 432 |
-
This method implements the TokenVerifier protocol by delegating
|
| 433 |
-
to our existing load_access_token method.
|
| 434 |
-
|
| 435 |
-
Args:
|
| 436 |
-
token: The JWT token string to validate
|
| 437 |
-
|
| 438 |
-
Returns:
|
| 439 |
-
AccessToken object if valid, None if invalid or expired
|
| 440 |
-
"""
|
| 441 |
-
return await self.load_access_token(token)
|
| 442 |
-
|
| 443 |
-
# --- Unused OAuth server methods ---
|
| 444 |
-
async def get_client(self, client_id: str) -> OAuthClientInformationFull | None:
|
| 445 |
-
raise NotImplementedError("Client management not supported")
|
| 446 |
-
|
| 447 |
-
async def register_client(self, client_info: OAuthClientInformationFull) -> None:
|
| 448 |
-
raise NotImplementedError("Client registration not supported")
|
| 449 |
-
|
| 450 |
-
async def authorize(
|
| 451 |
-
self, client: OAuthClientInformationFull, params: AuthorizationParams
|
| 452 |
-
) -> str:
|
| 453 |
-
raise NotImplementedError("Authorization flow not supported")
|
| 454 |
-
|
| 455 |
-
async def load_authorization_code(
|
| 456 |
-
self, client: OAuthClientInformationFull, authorization_code: str
|
| 457 |
-
) -> AuthorizationCode | None:
|
| 458 |
-
raise NotImplementedError("Authorization code flow not supported")
|
| 459 |
-
|
| 460 |
-
async def exchange_authorization_code(
|
| 461 |
-
self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode
|
| 462 |
-
) -> OAuthToken:
|
| 463 |
-
raise NotImplementedError("Authorization code exchange not supported")
|
| 464 |
-
|
| 465 |
-
async def load_refresh_token(
|
| 466 |
-
self, client: OAuthClientInformationFull, refresh_token: str
|
| 467 |
-
) -> RefreshToken | None:
|
| 468 |
-
raise NotImplementedError("Refresh token flow not supported")
|
| 469 |
-
|
| 470 |
-
async def exchange_refresh_token(
|
| 471 |
-
self,
|
| 472 |
-
client: OAuthClientInformationFull,
|
| 473 |
-
refresh_token: RefreshToken,
|
| 474 |
-
scopes: list[str],
|
| 475 |
-
) -> OAuthToken:
|
| 476 |
-
raise NotImplementedError("Refresh token exchange not supported")
|
| 477 |
-
|
| 478 |
-
async def revoke_token(
|
| 479 |
-
self,
|
| 480 |
-
token: AccessToken | RefreshToken,
|
| 481 |
-
) -> None:
|
| 482 |
-
raise NotImplementedError("Token revocation not supported")
|
|
|
|
| 1 |
+
"""Backwards compatibility shim for BearerAuthProvider.
|
| 2 |
+
|
| 3 |
+
The BearerAuthProvider class has been moved to fastmcp.server.auth.verifiers.JWTVerifier
|
| 4 |
+
for better organization. This module provides a backwards-compatible import.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import warnings
|
| 8 |
+
|
| 9 |
+
import fastmcp
|
| 10 |
+
from fastmcp.server.auth.verifiers import JWKData, JWKSData, RSAKeyPair
|
| 11 |
+
from fastmcp.server.auth.verifiers import JWTVerifier as BearerAuthProvider
|
| 12 |
+
|
| 13 |
+
# Re-export for backwards compatibility
|
| 14 |
+
__all__ = ["BearerAuthProvider", "RSAKeyPair", "JWKData", "JWKSData"]
|
| 15 |
+
|
| 16 |
+
# Deprecated in 2.11
|
| 17 |
+
if fastmcp.settings.deprecation_warnings:
|
| 18 |
+
warnings.warn(
|
| 19 |
+
"The `fastmcp.server.auth.providers.bearer` module is deprecated "
|
| 20 |
+
"and will be removed in a future version. "
|
| 21 |
+
"Please use `fastmcp.server.auth.verifiers.JWTVerifier` "
|
| 22 |
+
"instead of this module's BearerAuthProvider.",
|
| 23 |
+
DeprecationWarning,
|
| 24 |
+
stacklevel=2,
|
| 25 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/fastmcp/server/auth/providers/bearer_env.py
DELETED
|
@@ -1,65 +0,0 @@
|
|
| 1 |
-
from types import EllipsisType
|
| 2 |
-
|
| 3 |
-
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 4 |
-
|
| 5 |
-
from fastmcp.server.auth.providers.bearer import BearerAuthProvider
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
class EnvBearerAuthProviderSettings(BaseSettings):
|
| 9 |
-
"""Settings for the BearerAuthProvider."""
|
| 10 |
-
|
| 11 |
-
model_config = SettingsConfigDict(
|
| 12 |
-
env_prefix="FASTMCP_AUTH_BEARER_",
|
| 13 |
-
env_file=".env",
|
| 14 |
-
extra="ignore",
|
| 15 |
-
)
|
| 16 |
-
|
| 17 |
-
public_key: str | None = None
|
| 18 |
-
jwks_uri: str | None = None
|
| 19 |
-
issuer: str | None = None
|
| 20 |
-
algorithm: str | None = None
|
| 21 |
-
audience: str | None = None
|
| 22 |
-
required_scopes: list[str] | None = None
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
class EnvBearerAuthProvider(BearerAuthProvider):
|
| 26 |
-
"""
|
| 27 |
-
A BearerAuthProvider that loads settings from environment variables. Any
|
| 28 |
-
providing setting will always take precedence over the environment
|
| 29 |
-
variables.
|
| 30 |
-
"""
|
| 31 |
-
|
| 32 |
-
def __init__(
|
| 33 |
-
self,
|
| 34 |
-
public_key: str | None | EllipsisType = ...,
|
| 35 |
-
jwks_uri: str | None | EllipsisType = ...,
|
| 36 |
-
issuer: str | None | EllipsisType = ...,
|
| 37 |
-
algorithm: str | None | EllipsisType = ...,
|
| 38 |
-
audience: str | None | EllipsisType = ...,
|
| 39 |
-
required_scopes: list[str] | None | EllipsisType = ...,
|
| 40 |
-
resource_server: str | None | EllipsisType = ...,
|
| 41 |
-
):
|
| 42 |
-
"""
|
| 43 |
-
Initialize the provider.
|
| 44 |
-
|
| 45 |
-
Args:
|
| 46 |
-
public_key: RSA public key in PEM format (for static key)
|
| 47 |
-
jwks_uri: URI to fetch keys from (for key rotation)
|
| 48 |
-
issuer: Expected issuer claim (optional)
|
| 49 |
-
algorithm: Algorithm to use for verification (optional)
|
| 50 |
-
audience: Expected audience claim (optional)
|
| 51 |
-
required_scopes: List of required scopes for access (optional)
|
| 52 |
-
"""
|
| 53 |
-
kwargs = {
|
| 54 |
-
"public_key": public_key,
|
| 55 |
-
"jwks_uri": jwks_uri,
|
| 56 |
-
"issuer": issuer,
|
| 57 |
-
"algorithm": algorithm,
|
| 58 |
-
"audience": audience,
|
| 59 |
-
"required_scopes": required_scopes,
|
| 60 |
-
"resource_server": resource_server,
|
| 61 |
-
}
|
| 62 |
-
settings = EnvBearerAuthProviderSettings(
|
| 63 |
-
**{k: v for k, v in kwargs.items() if v is not ...}
|
| 64 |
-
)
|
| 65 |
-
super().__init__(**settings.model_dump())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/fastmcp/server/auth/verifiers.py
ADDED
|
@@ -0,0 +1,718 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""TokenVerifier implementations for FastMCP."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import time
|
| 6 |
+
from dataclasses import dataclass
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
import httpx
|
| 10 |
+
from authlib.jose import JsonWebKey, JsonWebToken
|
| 11 |
+
from authlib.jose.errors import JoseError
|
| 12 |
+
from cryptography.hazmat.primitives import serialization
|
| 13 |
+
from cryptography.hazmat.primitives.asymmetric import rsa
|
| 14 |
+
from mcp.server.auth.provider import AccessToken
|
| 15 |
+
from pydantic import AnyHttpUrl, SecretStr, ValidationError
|
| 16 |
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 17 |
+
from typing_extensions import TypedDict
|
| 18 |
+
|
| 19 |
+
from fastmcp.server.auth.auth import TokenVerifier
|
| 20 |
+
from fastmcp.utilities.logging import get_logger
|
| 21 |
+
from fastmcp.utilities.types import NotSet, NotSetT
|
| 22 |
+
|
| 23 |
+
logger = get_logger(__name__)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class JWKData(TypedDict, total=False):
|
| 27 |
+
"""JSON Web Key data structure."""
|
| 28 |
+
|
| 29 |
+
kty: str # Key type (e.g., "RSA") - required
|
| 30 |
+
kid: str # Key ID (optional but recommended)
|
| 31 |
+
use: str # Usage (e.g., "sig")
|
| 32 |
+
alg: str # Algorithm (e.g., "RS256")
|
| 33 |
+
n: str # Modulus (for RSA keys)
|
| 34 |
+
e: str # Exponent (for RSA keys)
|
| 35 |
+
x5c: list[str] # X.509 certificate chain (for JWKs)
|
| 36 |
+
x5t: str # X.509 certificate thumbprint (for JWKs)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class JWKSData(TypedDict):
|
| 40 |
+
"""JSON Web Key Set data structure."""
|
| 41 |
+
|
| 42 |
+
keys: list[JWKData]
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
@dataclass(frozen=True, kw_only=True, repr=False)
|
| 46 |
+
class RSAKeyPair:
|
| 47 |
+
"""RSA key pair for JWT testing."""
|
| 48 |
+
|
| 49 |
+
private_key: SecretStr
|
| 50 |
+
public_key: str
|
| 51 |
+
|
| 52 |
+
@classmethod
|
| 53 |
+
def generate(cls) -> RSAKeyPair:
|
| 54 |
+
"""
|
| 55 |
+
Generate an RSA key pair for testing.
|
| 56 |
+
|
| 57 |
+
Returns:
|
| 58 |
+
RSAKeyPair: Generated key pair
|
| 59 |
+
"""
|
| 60 |
+
# Generate private key
|
| 61 |
+
private_key = rsa.generate_private_key(
|
| 62 |
+
public_exponent=65537,
|
| 63 |
+
key_size=2048,
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
# Serialize private key to PEM format
|
| 67 |
+
private_pem = private_key.private_bytes(
|
| 68 |
+
encoding=serialization.Encoding.PEM,
|
| 69 |
+
format=serialization.PrivateFormat.PKCS8,
|
| 70 |
+
encryption_algorithm=serialization.NoEncryption(),
|
| 71 |
+
).decode("utf-8")
|
| 72 |
+
|
| 73 |
+
# Serialize public key to PEM format
|
| 74 |
+
public_pem = (
|
| 75 |
+
private_key.public_key()
|
| 76 |
+
.public_bytes(
|
| 77 |
+
encoding=serialization.Encoding.PEM,
|
| 78 |
+
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
| 79 |
+
)
|
| 80 |
+
.decode("utf-8")
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
return cls(
|
| 84 |
+
private_key=SecretStr(private_pem),
|
| 85 |
+
public_key=public_pem,
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
def create_token(
|
| 89 |
+
self,
|
| 90 |
+
subject: str = "fastmcp-user",
|
| 91 |
+
issuer: str = "https://fastmcp.example.com",
|
| 92 |
+
audience: str | list[str] | None = None,
|
| 93 |
+
scopes: list[str] | None = None,
|
| 94 |
+
expires_in_seconds: int = 3600,
|
| 95 |
+
additional_claims: dict[str, Any] | None = None,
|
| 96 |
+
kid: str | None = None,
|
| 97 |
+
) -> str:
|
| 98 |
+
"""
|
| 99 |
+
Generate a test JWT token for testing purposes.
|
| 100 |
+
|
| 101 |
+
Args:
|
| 102 |
+
subject: Subject claim (usually user ID)
|
| 103 |
+
issuer: Issuer claim
|
| 104 |
+
audience: Audience claim - can be a string or list of strings (optional)
|
| 105 |
+
scopes: List of scopes to include
|
| 106 |
+
expires_in_seconds: Token expiration time in seconds
|
| 107 |
+
additional_claims: Any additional claims to include
|
| 108 |
+
kid: Key ID to include in header
|
| 109 |
+
"""
|
| 110 |
+
import time
|
| 111 |
+
|
| 112 |
+
# Create header
|
| 113 |
+
header = {"alg": "RS256"}
|
| 114 |
+
if kid:
|
| 115 |
+
header["kid"] = kid
|
| 116 |
+
|
| 117 |
+
# Create payload
|
| 118 |
+
payload = {
|
| 119 |
+
"sub": subject,
|
| 120 |
+
"iss": issuer,
|
| 121 |
+
"iat": int(time.time()),
|
| 122 |
+
"exp": int(time.time()) + expires_in_seconds,
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
if audience:
|
| 126 |
+
payload["aud"] = audience
|
| 127 |
+
|
| 128 |
+
if scopes:
|
| 129 |
+
payload["scope"] = " ".join(scopes)
|
| 130 |
+
|
| 131 |
+
if additional_claims:
|
| 132 |
+
payload.update(additional_claims)
|
| 133 |
+
|
| 134 |
+
# Create JWT
|
| 135 |
+
jwt_lib = JsonWebToken(["RS256"])
|
| 136 |
+
token_bytes = jwt_lib.encode(
|
| 137 |
+
header, payload, self.private_key.get_secret_value()
|
| 138 |
+
)
|
| 139 |
+
return (
|
| 140 |
+
token_bytes.decode("utf-8")
|
| 141 |
+
if isinstance(token_bytes, bytes)
|
| 142 |
+
else token_bytes
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
class JWTVerifier(TokenVerifier):
|
| 147 |
+
"""
|
| 148 |
+
JWT token verifier using public key or JWKS.
|
| 149 |
+
|
| 150 |
+
This verifier validates JWT tokens signed by an external issuer. It's ideal for
|
| 151 |
+
scenarios where you have a centralized identity provider (like Auth0, Okta, or
|
| 152 |
+
your own OAuth server) that issues JWTs, and your FastMCP server acts as a
|
| 153 |
+
resource server validating those tokens.
|
| 154 |
+
|
| 155 |
+
Use this when:
|
| 156 |
+
- You have JWT tokens issued by an external service
|
| 157 |
+
- You want asymmetric key verification (public/private key pairs)
|
| 158 |
+
- You need JWKS support for automatic key rotation
|
| 159 |
+
- Your tokens contain standard OAuth scopes and claims
|
| 160 |
+
"""
|
| 161 |
+
|
| 162 |
+
def __init__(
|
| 163 |
+
self,
|
| 164 |
+
public_key: str | None = None,
|
| 165 |
+
jwks_uri: str | None = None,
|
| 166 |
+
issuer: str | None = None,
|
| 167 |
+
audience: str | list[str] | None = None,
|
| 168 |
+
algorithm: str | None = None,
|
| 169 |
+
required_scopes: list[str] | None = None,
|
| 170 |
+
resource_server_url: AnyHttpUrl | str | None = None,
|
| 171 |
+
):
|
| 172 |
+
"""
|
| 173 |
+
Initialize the JWT token verifier.
|
| 174 |
+
|
| 175 |
+
Args:
|
| 176 |
+
public_key: PEM-encoded public key for verification
|
| 177 |
+
jwks_uri: URI to fetch JSON Web Key Set
|
| 178 |
+
issuer: Expected issuer claim
|
| 179 |
+
audience: Expected audience claim(s)
|
| 180 |
+
algorithm: JWT signing algorithm (default: RS256)
|
| 181 |
+
required_scopes: Required scopes for all tokens
|
| 182 |
+
resource_server_url: Resource server URL for TokenVerifier protocol
|
| 183 |
+
"""
|
| 184 |
+
if not public_key and not jwks_uri:
|
| 185 |
+
raise ValueError("Either public_key or jwks_uri must be provided")
|
| 186 |
+
|
| 187 |
+
if public_key and jwks_uri:
|
| 188 |
+
raise ValueError("Provide either public_key or jwks_uri, not both")
|
| 189 |
+
|
| 190 |
+
if not algorithm:
|
| 191 |
+
algorithm = "RS256"
|
| 192 |
+
if algorithm not in {
|
| 193 |
+
"HS256",
|
| 194 |
+
"HS384",
|
| 195 |
+
"HS512",
|
| 196 |
+
"RS256",
|
| 197 |
+
"RS384",
|
| 198 |
+
"RS512",
|
| 199 |
+
"ES256",
|
| 200 |
+
"ES384",
|
| 201 |
+
"ES512",
|
| 202 |
+
"PS256",
|
| 203 |
+
"PS384",
|
| 204 |
+
"PS512",
|
| 205 |
+
}:
|
| 206 |
+
raise ValueError(f"Unsupported algorithm: {algorithm}.")
|
| 207 |
+
|
| 208 |
+
# Initialize parent TokenVerifier
|
| 209 |
+
super().__init__(
|
| 210 |
+
resource_server_url=resource_server_url, required_scopes=required_scopes
|
| 211 |
+
)
|
| 212 |
+
|
| 213 |
+
self.algorithm = algorithm
|
| 214 |
+
self.issuer = issuer
|
| 215 |
+
self.audience = audience
|
| 216 |
+
self.public_key = public_key
|
| 217 |
+
self.jwks_uri = jwks_uri
|
| 218 |
+
self.jwt = JsonWebToken([self.algorithm])
|
| 219 |
+
self.logger = get_logger(__name__)
|
| 220 |
+
|
| 221 |
+
# Simple JWKS cache
|
| 222 |
+
self._jwks_cache: dict[str, str] = {}
|
| 223 |
+
self._jwks_cache_time: float = 0
|
| 224 |
+
self._cache_ttl = 3600 # 1 hour
|
| 225 |
+
|
| 226 |
+
async def _get_verification_key(self, token: str) -> str:
|
| 227 |
+
"""Get the verification key for the token."""
|
| 228 |
+
if self.public_key:
|
| 229 |
+
return self.public_key
|
| 230 |
+
|
| 231 |
+
# Extract kid from token header for JWKS lookup
|
| 232 |
+
try:
|
| 233 |
+
import base64
|
| 234 |
+
import json
|
| 235 |
+
|
| 236 |
+
header_b64 = token.split(".")[0]
|
| 237 |
+
header_b64 += "=" * (4 - len(header_b64) % 4) # Add padding
|
| 238 |
+
header = json.loads(base64.urlsafe_b64decode(header_b64))
|
| 239 |
+
kid = header.get("kid")
|
| 240 |
+
|
| 241 |
+
return await self._get_jwks_key(kid)
|
| 242 |
+
|
| 243 |
+
except Exception as e:
|
| 244 |
+
raise ValueError(f"Failed to extract key ID from token: {e}")
|
| 245 |
+
|
| 246 |
+
async def _get_jwks_key(self, kid: str | None) -> str:
|
| 247 |
+
"""Fetch key from JWKS with simple caching."""
|
| 248 |
+
if not self.jwks_uri:
|
| 249 |
+
raise ValueError("JWKS URI not configured")
|
| 250 |
+
|
| 251 |
+
current_time = time.time()
|
| 252 |
+
|
| 253 |
+
# Check cache first
|
| 254 |
+
if current_time - self._jwks_cache_time < self._cache_ttl:
|
| 255 |
+
if kid and kid in self._jwks_cache:
|
| 256 |
+
return self._jwks_cache[kid]
|
| 257 |
+
elif not kid and len(self._jwks_cache) == 1:
|
| 258 |
+
# If no kid but only one key cached, use it
|
| 259 |
+
return next(iter(self._jwks_cache.values()))
|
| 260 |
+
|
| 261 |
+
# Fetch JWKS
|
| 262 |
+
try:
|
| 263 |
+
async with httpx.AsyncClient() as client:
|
| 264 |
+
response = await client.get(self.jwks_uri)
|
| 265 |
+
response.raise_for_status()
|
| 266 |
+
jwks_data = response.json()
|
| 267 |
+
|
| 268 |
+
# Cache all keys
|
| 269 |
+
self._jwks_cache = {}
|
| 270 |
+
for key_data in jwks_data.get("keys", []):
|
| 271 |
+
key_kid = key_data.get("kid")
|
| 272 |
+
jwk = JsonWebKey.import_key(key_data)
|
| 273 |
+
public_key = jwk.get_public_key() # type: ignore
|
| 274 |
+
|
| 275 |
+
if key_kid:
|
| 276 |
+
self._jwks_cache[key_kid] = public_key
|
| 277 |
+
else:
|
| 278 |
+
# Key without kid - use a default identifier
|
| 279 |
+
self._jwks_cache["_default"] = public_key
|
| 280 |
+
|
| 281 |
+
self._jwks_cache_time = current_time
|
| 282 |
+
|
| 283 |
+
# Select the appropriate key
|
| 284 |
+
if kid:
|
| 285 |
+
if kid not in self._jwks_cache:
|
| 286 |
+
self.logger.debug(
|
| 287 |
+
"JWKS key lookup failed: key ID '%s' not found", kid
|
| 288 |
+
)
|
| 289 |
+
raise ValueError(f"Key ID '{kid}' not found in JWKS")
|
| 290 |
+
return self._jwks_cache[kid]
|
| 291 |
+
else:
|
| 292 |
+
# No kid in token - only allow if there's exactly one key
|
| 293 |
+
if len(self._jwks_cache) == 1:
|
| 294 |
+
return next(iter(self._jwks_cache.values()))
|
| 295 |
+
elif len(self._jwks_cache) > 1:
|
| 296 |
+
raise ValueError(
|
| 297 |
+
"Multiple keys in JWKS but no key ID (kid) in token"
|
| 298 |
+
)
|
| 299 |
+
else:
|
| 300 |
+
raise ValueError("No keys found in JWKS")
|
| 301 |
+
|
| 302 |
+
except httpx.HTTPError as e:
|
| 303 |
+
raise ValueError(f"Failed to fetch JWKS: {e}")
|
| 304 |
+
except Exception as e:
|
| 305 |
+
self.logger.debug(f"JWKS fetch failed: {e}")
|
| 306 |
+
raise ValueError(f"Failed to fetch JWKS: {e}")
|
| 307 |
+
|
| 308 |
+
def _extract_scopes(self, claims: dict[str, Any]) -> list[str]:
|
| 309 |
+
"""
|
| 310 |
+
Extract scopes from JWT claims. Supports both 'scope' and 'scp'
|
| 311 |
+
claims.
|
| 312 |
+
|
| 313 |
+
Checks the `scope` claim first (standard OAuth2 claim), then the `scp`
|
| 314 |
+
claim (used by some Identity Providers).
|
| 315 |
+
"""
|
| 316 |
+
for claim in ["scope", "scp"]:
|
| 317 |
+
if claim in claims:
|
| 318 |
+
if isinstance(claims[claim], str):
|
| 319 |
+
return claims[claim].split()
|
| 320 |
+
elif isinstance(claims[claim], list):
|
| 321 |
+
return claims[claim]
|
| 322 |
+
|
| 323 |
+
return []
|
| 324 |
+
|
| 325 |
+
async def load_access_token(self, token: str) -> AccessToken | None:
|
| 326 |
+
"""
|
| 327 |
+
Validates the provided JWT bearer token.
|
| 328 |
+
|
| 329 |
+
Args:
|
| 330 |
+
token: The JWT token string to validate
|
| 331 |
+
|
| 332 |
+
Returns:
|
| 333 |
+
AccessToken object if valid, None if invalid or expired
|
| 334 |
+
"""
|
| 335 |
+
try:
|
| 336 |
+
# Get verification key (static or from JWKS)
|
| 337 |
+
verification_key = await self._get_verification_key(token)
|
| 338 |
+
|
| 339 |
+
# Decode and verify the JWT token
|
| 340 |
+
claims = self.jwt.decode(token, verification_key)
|
| 341 |
+
|
| 342 |
+
# Extract client ID early for logging
|
| 343 |
+
client_id = claims.get("client_id") or claims.get("sub") or "unknown"
|
| 344 |
+
|
| 345 |
+
# Validate expiration
|
| 346 |
+
exp = claims.get("exp")
|
| 347 |
+
if exp and exp < time.time():
|
| 348 |
+
self.logger.debug(
|
| 349 |
+
"Token validation failed: expired token for client %s", client_id
|
| 350 |
+
)
|
| 351 |
+
self.logger.info("Bearer token rejected for client %s", client_id)
|
| 352 |
+
return None
|
| 353 |
+
|
| 354 |
+
# Validate issuer - note we use issuer instead of issuer_url here because
|
| 355 |
+
# issuer is optional, allowing users to make this check optional
|
| 356 |
+
if self.issuer:
|
| 357 |
+
if claims.get("iss") != self.issuer:
|
| 358 |
+
self.logger.debug(
|
| 359 |
+
"Token validation failed: issuer mismatch for client %s",
|
| 360 |
+
client_id,
|
| 361 |
+
)
|
| 362 |
+
self.logger.info("Bearer token rejected for client %s", client_id)
|
| 363 |
+
return None
|
| 364 |
+
|
| 365 |
+
# Validate audience if configured
|
| 366 |
+
if self.audience:
|
| 367 |
+
aud = claims.get("aud")
|
| 368 |
+
|
| 369 |
+
# Handle different combinations of audience types
|
| 370 |
+
audience_valid = False
|
| 371 |
+
if isinstance(self.audience, list):
|
| 372 |
+
# self.audience is a list - check if any expected audience is present
|
| 373 |
+
if isinstance(aud, list):
|
| 374 |
+
# Both are lists - check for intersection
|
| 375 |
+
audience_valid = any(
|
| 376 |
+
expected in aud for expected in self.audience
|
| 377 |
+
)
|
| 378 |
+
else:
|
| 379 |
+
# aud is a string - check if it's in our expected list
|
| 380 |
+
audience_valid = aud in self.audience
|
| 381 |
+
else:
|
| 382 |
+
# self.audience is a string - use original logic
|
| 383 |
+
if isinstance(aud, list):
|
| 384 |
+
audience_valid = self.audience in aud
|
| 385 |
+
else:
|
| 386 |
+
audience_valid = aud == self.audience
|
| 387 |
+
|
| 388 |
+
if not audience_valid:
|
| 389 |
+
self.logger.debug(
|
| 390 |
+
"Token validation failed: audience mismatch for client %s",
|
| 391 |
+
client_id,
|
| 392 |
+
)
|
| 393 |
+
self.logger.info("Bearer token rejected for client %s", client_id)
|
| 394 |
+
return None
|
| 395 |
+
|
| 396 |
+
# Extract scopes
|
| 397 |
+
scopes = self._extract_scopes(claims)
|
| 398 |
+
|
| 399 |
+
# Check required scopes
|
| 400 |
+
if self.required_scopes:
|
| 401 |
+
token_scopes = set(scopes)
|
| 402 |
+
required_scopes = set(self.required_scopes)
|
| 403 |
+
if not required_scopes.issubset(token_scopes):
|
| 404 |
+
self.logger.debug(
|
| 405 |
+
"Token missing required scopes. Has: %s, Required: %s",
|
| 406 |
+
token_scopes,
|
| 407 |
+
required_scopes,
|
| 408 |
+
)
|
| 409 |
+
self.logger.info("Bearer token rejected for client %s", client_id)
|
| 410 |
+
return None
|
| 411 |
+
|
| 412 |
+
return AccessToken(
|
| 413 |
+
token=token,
|
| 414 |
+
client_id=str(client_id),
|
| 415 |
+
scopes=scopes,
|
| 416 |
+
expires_at=int(exp) if exp else None,
|
| 417 |
+
)
|
| 418 |
+
|
| 419 |
+
except JoseError:
|
| 420 |
+
self.logger.debug("Token validation failed: JWT signature/format invalid")
|
| 421 |
+
return None
|
| 422 |
+
except Exception as e:
|
| 423 |
+
self.logger.debug("Token validation failed: %s", str(e))
|
| 424 |
+
return None
|
| 425 |
+
|
| 426 |
+
async def verify_token(self, token: str) -> AccessToken | None:
|
| 427 |
+
"""
|
| 428 |
+
Verify a bearer token and return access info if valid.
|
| 429 |
+
|
| 430 |
+
This method implements the TokenVerifier protocol by delegating
|
| 431 |
+
to our existing load_access_token method.
|
| 432 |
+
|
| 433 |
+
Args:
|
| 434 |
+
token: The JWT token string to validate
|
| 435 |
+
|
| 436 |
+
Returns:
|
| 437 |
+
AccessToken object if valid, None if invalid or expired
|
| 438 |
+
"""
|
| 439 |
+
return await self.load_access_token(token)
|
| 440 |
+
|
| 441 |
+
|
| 442 |
+
class JWTVerifierSettings(BaseSettings):
|
| 443 |
+
"""Settings for the BearerAuthProvider."""
|
| 444 |
+
|
| 445 |
+
model_config = SettingsConfigDict(
|
| 446 |
+
env_prefix="FASTMCP_AUTH_JWT_",
|
| 447 |
+
env_file=".env",
|
| 448 |
+
extra="ignore",
|
| 449 |
+
)
|
| 450 |
+
|
| 451 |
+
public_key: str | None = None
|
| 452 |
+
jwks_uri: str | None = None
|
| 453 |
+
issuer: str | None = None
|
| 454 |
+
algorithm: str | None = None
|
| 455 |
+
audience: str | None = None
|
| 456 |
+
required_scopes: list[str] | None = None
|
| 457 |
+
resource_server_url: AnyHttpUrl | str | None = None
|
| 458 |
+
|
| 459 |
+
|
| 460 |
+
class EnvJWTVerifier(JWTVerifier):
|
| 461 |
+
def __init__(
|
| 462 |
+
self,
|
| 463 |
+
public_key: str | None | NotSetT = NotSet,
|
| 464 |
+
jwks_uri: str | None | NotSetT = NotSet,
|
| 465 |
+
issuer: str | None | NotSetT = NotSet,
|
| 466 |
+
audience: str | list[str] | None | NotSetT = NotSet,
|
| 467 |
+
algorithm: str | None | NotSetT = NotSet,
|
| 468 |
+
required_scopes: list[str] | None | NotSetT = NotSet,
|
| 469 |
+
resource_server_url: AnyHttpUrl | str | None | NotSetT = NotSet,
|
| 470 |
+
):
|
| 471 |
+
kwargs = {
|
| 472 |
+
"public_key": public_key,
|
| 473 |
+
"jwks_uri": jwks_uri,
|
| 474 |
+
"issuer": issuer,
|
| 475 |
+
"algorithm": algorithm,
|
| 476 |
+
"audience": audience,
|
| 477 |
+
"required_scopes": required_scopes,
|
| 478 |
+
"resource_server_url": resource_server_url,
|
| 479 |
+
}
|
| 480 |
+
settings = JWTVerifierSettings(
|
| 481 |
+
**{k: v for k, v in kwargs.items() if v is not NotSet}
|
| 482 |
+
)
|
| 483 |
+
super().__init__(**settings.model_dump())
|
| 484 |
+
|
| 485 |
+
|
| 486 |
+
class IntrospectionTokenVerifier(TokenVerifier):
|
| 487 |
+
"""
|
| 488 |
+
OAuth 2.0 Token Introspection verifier (RFC 7662).
|
| 489 |
+
|
| 490 |
+
This verifier validates tokens by making real-time calls to an OAuth 2.0
|
| 491 |
+
authorization server's introspection endpoint. Unlike JWT verification, this
|
| 492 |
+
approach works with both opaque tokens and JWTs, and provides real-time
|
| 493 |
+
validation including immediate revocation support.
|
| 494 |
+
|
| 495 |
+
Use this when:
|
| 496 |
+
- Your authorization server is separate from your FastMCP server
|
| 497 |
+
- You're using opaque (non-JWT) tokens
|
| 498 |
+
- You need real-time token validation and revocation support
|
| 499 |
+
- Your authorization server supports RFC 7662 introspection
|
| 500 |
+
- You want centralized token management without sharing secrets
|
| 501 |
+
"""
|
| 502 |
+
|
| 503 |
+
def __init__(
|
| 504 |
+
self,
|
| 505 |
+
introspection_endpoint: AnyHttpUrl | str,
|
| 506 |
+
server_url: AnyHttpUrl | str,
|
| 507 |
+
client_id: str | None = None,
|
| 508 |
+
client_secret: str | None = None,
|
| 509 |
+
validate_resource: bool = False,
|
| 510 |
+
required_scopes: list[str] | None = None,
|
| 511 |
+
timeout: float = 10.0,
|
| 512 |
+
):
|
| 513 |
+
"""
|
| 514 |
+
Initialize the introspection token verifier.
|
| 515 |
+
|
| 516 |
+
Args:
|
| 517 |
+
introspection_endpoint: OAuth 2.0 introspection endpoint URL
|
| 518 |
+
server_url: This server's URL for resource validation
|
| 519 |
+
client_id: Client ID for introspection authentication
|
| 520 |
+
client_secret: Client secret for introspection authentication
|
| 521 |
+
validate_resource: Whether to validate RFC 8707 resource parameter
|
| 522 |
+
required_scopes: Required scopes for all tokens
|
| 523 |
+
timeout: HTTP request timeout in seconds
|
| 524 |
+
"""
|
| 525 |
+
try:
|
| 526 |
+
self.introspection_endpoint = AnyHttpUrl(introspection_endpoint)
|
| 527 |
+
server_url_validated = AnyHttpUrl(server_url)
|
| 528 |
+
except ValidationError as e:
|
| 529 |
+
raise ValueError(f"Invalid URL provided: {e}") from e
|
| 530 |
+
|
| 531 |
+
# Basic SSRF protection - reject private/localhost URLs
|
| 532 |
+
if self._is_private_url(str(self.introspection_endpoint)):
|
| 533 |
+
raise ValueError("Introspection endpoint cannot be a private/localhost URL")
|
| 534 |
+
|
| 535 |
+
# Initialize parent TokenVerifier with the resource server URL
|
| 536 |
+
super().__init__(
|
| 537 |
+
resource_server_url=server_url_validated, required_scopes=required_scopes
|
| 538 |
+
)
|
| 539 |
+
|
| 540 |
+
self.client_id = client_id
|
| 541 |
+
self.client_secret = client_secret
|
| 542 |
+
self.validate_resource = validate_resource
|
| 543 |
+
self.timeout = timeout
|
| 544 |
+
|
| 545 |
+
# Create HTTP client with security settings
|
| 546 |
+
self._client = httpx.AsyncClient(
|
| 547 |
+
timeout=timeout,
|
| 548 |
+
verify=True, # Always verify SSL
|
| 549 |
+
limits=httpx.Limits(max_connections=10, max_keepalive_connections=5),
|
| 550 |
+
)
|
| 551 |
+
|
| 552 |
+
@property
|
| 553 |
+
def server_url(self) -> AnyHttpUrl:
|
| 554 |
+
"""The resource server URL for this verifier."""
|
| 555 |
+
if self.resource_server_url is None:
|
| 556 |
+
raise ValueError("Resource server URL not set")
|
| 557 |
+
return self.resource_server_url
|
| 558 |
+
|
| 559 |
+
def _is_private_url(self, url: str) -> bool:
|
| 560 |
+
"""Check if URL points to private/localhost addresses (basic SSRF protection)."""
|
| 561 |
+
import ipaddress
|
| 562 |
+
from urllib.parse import urlparse
|
| 563 |
+
|
| 564 |
+
parsed = urlparse(url)
|
| 565 |
+
hostname = parsed.hostname
|
| 566 |
+
|
| 567 |
+
if not hostname:
|
| 568 |
+
return False
|
| 569 |
+
|
| 570 |
+
# Check for localhost
|
| 571 |
+
if hostname.lower() in ("localhost", "127.0.0.1", "::1"):
|
| 572 |
+
return True
|
| 573 |
+
|
| 574 |
+
# Check for private IP ranges
|
| 575 |
+
try:
|
| 576 |
+
ip = ipaddress.ip_address(hostname)
|
| 577 |
+
return ip.is_private or ip.is_loopback
|
| 578 |
+
except ValueError:
|
| 579 |
+
# Not an IP address, assume it's a hostname
|
| 580 |
+
return False
|
| 581 |
+
|
| 582 |
+
async def verify_token(self, token: str) -> AccessToken | None:
|
| 583 |
+
"""Verify token using OAuth 2.0 introspection."""
|
| 584 |
+
try:
|
| 585 |
+
# Prepare introspection request
|
| 586 |
+
data = {"token": token}
|
| 587 |
+
|
| 588 |
+
# Add resource parameter if validation is enabled (RFC 8707)
|
| 589 |
+
if self.validate_resource and self.resource_server_url:
|
| 590 |
+
data["resource"] = str(self.resource_server_url)
|
| 591 |
+
|
| 592 |
+
# Prepare authentication and make introspection request
|
| 593 |
+
if self.client_id and self.client_secret:
|
| 594 |
+
response = await self._client.post(
|
| 595 |
+
str(self.introspection_endpoint),
|
| 596 |
+
data=data,
|
| 597 |
+
auth=(self.client_id, self.client_secret),
|
| 598 |
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
| 599 |
+
)
|
| 600 |
+
else:
|
| 601 |
+
response = await self._client.post(
|
| 602 |
+
str(self.introspection_endpoint),
|
| 603 |
+
data=data,
|
| 604 |
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
| 605 |
+
)
|
| 606 |
+
response.raise_for_status()
|
| 607 |
+
|
| 608 |
+
introspection_response = response.json()
|
| 609 |
+
|
| 610 |
+
# Check if token is active
|
| 611 |
+
if not introspection_response.get("active", False):
|
| 612 |
+
return None
|
| 613 |
+
|
| 614 |
+
# Extract token information
|
| 615 |
+
client_id = introspection_response.get("client_id", "unknown")
|
| 616 |
+
scopes = (
|
| 617 |
+
introspection_response.get("scope", "").split()
|
| 618 |
+
if introspection_response.get("scope")
|
| 619 |
+
else []
|
| 620 |
+
)
|
| 621 |
+
exp = introspection_response.get("exp")
|
| 622 |
+
|
| 623 |
+
# Check required scopes
|
| 624 |
+
if self.required_scopes:
|
| 625 |
+
token_scopes = set(scopes)
|
| 626 |
+
required_scopes = set(self.required_scopes)
|
| 627 |
+
if not required_scopes.issubset(token_scopes):
|
| 628 |
+
logger.debug(
|
| 629 |
+
f"Token missing required scopes. Has: {token_scopes}, Required: {required_scopes}"
|
| 630 |
+
)
|
| 631 |
+
return None
|
| 632 |
+
|
| 633 |
+
return AccessToken(
|
| 634 |
+
token=token,
|
| 635 |
+
client_id=client_id,
|
| 636 |
+
scopes=scopes,
|
| 637 |
+
expires_at=exp,
|
| 638 |
+
resource=str(self.resource_server_url)
|
| 639 |
+
if self.resource_server_url
|
| 640 |
+
else None,
|
| 641 |
+
)
|
| 642 |
+
|
| 643 |
+
except Exception as e:
|
| 644 |
+
logger.debug(f"Introspection verification failed: {e}")
|
| 645 |
+
return None
|
| 646 |
+
|
| 647 |
+
async def __aenter__(self):
|
| 648 |
+
"""Async context manager entry."""
|
| 649 |
+
return self
|
| 650 |
+
|
| 651 |
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
| 652 |
+
"""Async context manager exit."""
|
| 653 |
+
await self._client.aclose()
|
| 654 |
+
|
| 655 |
+
|
| 656 |
+
class StaticTokenVerifier(TokenVerifier):
|
| 657 |
+
"""
|
| 658 |
+
Simple static token verifier for testing and development.
|
| 659 |
+
|
| 660 |
+
This verifier validates tokens against a predefined dictionary of valid token
|
| 661 |
+
strings and their associated claims. When a token string matches a key in the
|
| 662 |
+
dictionary, the verifier returns the corresponding claims as if the token was
|
| 663 |
+
validated by a real authorization server.
|
| 664 |
+
|
| 665 |
+
Use this when:
|
| 666 |
+
- You're developing or testing locally without a real OAuth server
|
| 667 |
+
- You need predictable tokens for automated testing
|
| 668 |
+
- You want to simulate different users/scopes without complex setup
|
| 669 |
+
- You're prototyping and need simple API key-style authentication
|
| 670 |
+
|
| 671 |
+
WARNING: Never use this in production - tokens are stored in plain text!
|
| 672 |
+
"""
|
| 673 |
+
|
| 674 |
+
def __init__(
|
| 675 |
+
self,
|
| 676 |
+
tokens: dict[str, dict[str, Any]],
|
| 677 |
+
required_scopes: list[str] | None = None,
|
| 678 |
+
):
|
| 679 |
+
"""
|
| 680 |
+
Initialize the static token verifier.
|
| 681 |
+
|
| 682 |
+
Args:
|
| 683 |
+
tokens: Dict mapping token strings to token metadata
|
| 684 |
+
Each token should have: client_id, scopes, expires_at (optional)
|
| 685 |
+
required_scopes: Required scopes for all tokens
|
| 686 |
+
"""
|
| 687 |
+
super().__init__(required_scopes=required_scopes)
|
| 688 |
+
self.tokens = tokens
|
| 689 |
+
|
| 690 |
+
async def verify_token(self, token: str) -> AccessToken | None:
|
| 691 |
+
"""Verify token against static token dictionary."""
|
| 692 |
+
token_data = self.tokens.get(token)
|
| 693 |
+
if not token_data:
|
| 694 |
+
return None
|
| 695 |
+
|
| 696 |
+
# Check expiration if present
|
| 697 |
+
expires_at = token_data.get("expires_at")
|
| 698 |
+
if expires_at is not None and expires_at < time.time():
|
| 699 |
+
return None
|
| 700 |
+
|
| 701 |
+
scopes = token_data.get("scopes", [])
|
| 702 |
+
|
| 703 |
+
# Check required scopes
|
| 704 |
+
if self.required_scopes:
|
| 705 |
+
token_scopes = set(scopes)
|
| 706 |
+
required_scopes = set(self.required_scopes)
|
| 707 |
+
if not required_scopes.issubset(token_scopes):
|
| 708 |
+
logger.debug(
|
| 709 |
+
f"Token missing required scopes. Has: {token_scopes}, Required: {required_scopes}"
|
| 710 |
+
)
|
| 711 |
+
return None
|
| 712 |
+
|
| 713 |
+
return AccessToken(
|
| 714 |
+
token=token,
|
| 715 |
+
client_id=token_data["client_id"],
|
| 716 |
+
scopes=scopes,
|
| 717 |
+
expires_at=expires_at,
|
| 718 |
+
)
|
src/fastmcp/server/http.py
CHANGED
|
@@ -3,13 +3,14 @@ from __future__ import annotations
|
|
| 3 |
from collections.abc import AsyncGenerator, Callable, Generator
|
| 4 |
from contextlib import asynccontextmanager, contextmanager
|
| 5 |
from contextvars import ContextVar
|
| 6 |
-
from typing import TYPE_CHECKING
|
| 7 |
|
| 8 |
from mcp.server.auth.middleware.auth_context import AuthContextMiddleware
|
| 9 |
from mcp.server.auth.middleware.bearer_auth import (
|
| 10 |
BearerAuthBackend,
|
| 11 |
RequireAuthMiddleware,
|
| 12 |
)
|
|
|
|
| 13 |
from mcp.server.auth.routes import create_auth_routes
|
| 14 |
from mcp.server.lowlevel.server import LifespanResultT
|
| 15 |
from mcp.server.sse import SseServerTransport
|
|
@@ -24,7 +25,7 @@ from starlette.responses import Response
|
|
| 24 |
from starlette.routing import BaseRoute, Mount, Route
|
| 25 |
from starlette.types import Lifespan, Receive, Scope, Send
|
| 26 |
|
| 27 |
-
from fastmcp.server.auth.auth import OAuthProvider
|
| 28 |
from fastmcp.utilities.logging import get_logger
|
| 29 |
|
| 30 |
if TYPE_CHECKING:
|
|
@@ -71,39 +72,45 @@ class RequestContextMiddleware:
|
|
| 71 |
|
| 72 |
|
| 73 |
def setup_auth_middleware_and_routes(
|
| 74 |
-
auth: OAuthProvider,
|
| 75 |
) -> tuple[list[Middleware], list[BaseRoute], list[str]]:
|
| 76 |
"""Set up authentication middleware and routes if auth is enabled.
|
| 77 |
|
| 78 |
Args:
|
| 79 |
-
auth:
|
| 80 |
|
| 81 |
Returns:
|
| 82 |
Tuple of (middleware, auth_routes, required_scopes)
|
| 83 |
"""
|
| 84 |
-
middleware: list[Middleware] = [
|
| 85 |
-
auth_routes: list[BaseRoute] = []
|
| 86 |
-
required_scopes: list[str] = []
|
| 87 |
-
|
| 88 |
-
middleware = [
|
| 89 |
Middleware(
|
| 90 |
AuthenticationMiddleware,
|
| 91 |
-
backend=BearerAuthBackend(auth),
|
| 92 |
),
|
| 93 |
Middleware(AuthContextMiddleware),
|
| 94 |
]
|
| 95 |
|
| 96 |
-
|
|
|
|
| 97 |
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
)
|
| 106 |
-
|
|
|
|
|
|
|
|
|
|
| 107 |
|
| 108 |
return middleware, auth_routes, required_scopes
|
| 109 |
|
|
@@ -140,7 +147,7 @@ def create_sse_app(
|
|
| 140 |
server: FastMCP[LifespanResultT],
|
| 141 |
message_path: str,
|
| 142 |
sse_path: str,
|
| 143 |
-
auth: OAuthProvider | None = None,
|
| 144 |
debug: bool = False,
|
| 145 |
routes: list[BaseRoute] | None = None,
|
| 146 |
middleware: list[Middleware] | None = None,
|
|
@@ -151,7 +158,7 @@ def create_sse_app(
|
|
| 151 |
server: The FastMCP server instance
|
| 152 |
message_path: Path for SSE messages
|
| 153 |
sse_path: Path for SSE connections
|
| 154 |
-
auth: Optional
|
| 155 |
debug: Whether to enable debug mode
|
| 156 |
routes: Optional list of custom routes
|
| 157 |
middleware: Optional list of middleware
|
|
@@ -176,8 +183,6 @@ def create_sse_app(
|
|
| 176 |
return Response()
|
| 177 |
|
| 178 |
# Get auth middleware and routes
|
| 179 |
-
|
| 180 |
-
# Add SSE routes with or without auth
|
| 181 |
if auth:
|
| 182 |
auth_middleware, auth_routes, required_scopes = (
|
| 183 |
setup_auth_middleware_and_routes(auth)
|
|
@@ -185,18 +190,32 @@ def create_sse_app(
|
|
| 185 |
|
| 186 |
server_routes.extend(auth_routes)
|
| 187 |
server_middleware.extend(auth_middleware)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
# Auth is enabled, wrap endpoints with RequireAuthMiddleware
|
| 189 |
server_routes.append(
|
| 190 |
Route(
|
| 191 |
sse_path,
|
| 192 |
-
endpoint=RequireAuthMiddleware(
|
|
|
|
|
|
|
| 193 |
methods=["GET"],
|
| 194 |
)
|
| 195 |
)
|
| 196 |
server_routes.append(
|
| 197 |
Mount(
|
| 198 |
message_path,
|
| 199 |
-
app=RequireAuthMiddleware(
|
|
|
|
|
|
|
| 200 |
)
|
| 201 |
)
|
| 202 |
else:
|
|
@@ -244,7 +263,7 @@ def create_streamable_http_app(
|
|
| 244 |
server: FastMCP[LifespanResultT],
|
| 245 |
streamable_http_path: str,
|
| 246 |
event_store: EventStore | None = None,
|
| 247 |
-
auth: OAuthProvider | None = None,
|
| 248 |
json_response: bool = False,
|
| 249 |
stateless_http: bool = False,
|
| 250 |
debug: bool = False,
|
|
@@ -257,7 +276,7 @@ def create_streamable_http_app(
|
|
| 257 |
server: The FastMCP server instance
|
| 258 |
streamable_http_path: Path for StreamableHTTP connections
|
| 259 |
event_store: Optional event store for session management
|
| 260 |
-
auth: Optional
|
| 261 |
json_response: Whether to use JSON response format
|
| 262 |
stateless_http: Whether to use stateless mode (new transport per request)
|
| 263 |
debug: Whether to enable debug mode
|
|
@@ -310,7 +329,7 @@ def create_streamable_http_app(
|
|
| 310 |
if auth:
|
| 311 |
resource_metadata_url = None
|
| 312 |
|
| 313 |
-
if auth.resource_server_url:
|
| 314 |
resource_metadata_url = AnyHttpUrl(
|
| 315 |
str(auth.resource_server_url).rstrip("/")
|
| 316 |
+ "/.well-known/oauth-protected-resource"
|
|
@@ -323,6 +342,15 @@ def create_streamable_http_app(
|
|
| 323 |
server_routes.extend(auth_routes)
|
| 324 |
server_middleware.extend(auth_middleware)
|
| 325 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 326 |
# Auth is enabled, wrap endpoint with RequireAuthMiddleware
|
| 327 |
server_routes.append(
|
| 328 |
Mount(
|
|
|
|
| 3 |
from collections.abc import AsyncGenerator, Callable, Generator
|
| 4 |
from contextlib import asynccontextmanager, contextmanager
|
| 5 |
from contextvars import ContextVar
|
| 6 |
+
from typing import TYPE_CHECKING, cast
|
| 7 |
|
| 8 |
from mcp.server.auth.middleware.auth_context import AuthContextMiddleware
|
| 9 |
from mcp.server.auth.middleware.bearer_auth import (
|
| 10 |
BearerAuthBackend,
|
| 11 |
RequireAuthMiddleware,
|
| 12 |
)
|
| 13 |
+
from mcp.server.auth.provider import TokenVerifier as TokenVerifierProtocol
|
| 14 |
from mcp.server.auth.routes import create_auth_routes
|
| 15 |
from mcp.server.lowlevel.server import LifespanResultT
|
| 16 |
from mcp.server.sse import SseServerTransport
|
|
|
|
| 25 |
from starlette.routing import BaseRoute, Mount, Route
|
| 26 |
from starlette.types import Lifespan, Receive, Scope, Send
|
| 27 |
|
| 28 |
+
from fastmcp.server.auth.auth import OAuthProvider, TokenVerifier
|
| 29 |
from fastmcp.utilities.logging import get_logger
|
| 30 |
|
| 31 |
if TYPE_CHECKING:
|
|
|
|
| 72 |
|
| 73 |
|
| 74 |
def setup_auth_middleware_and_routes(
|
| 75 |
+
auth: OAuthProvider | TokenVerifier,
|
| 76 |
) -> tuple[list[Middleware], list[BaseRoute], list[str]]:
|
| 77 |
"""Set up authentication middleware and routes if auth is enabled.
|
| 78 |
|
| 79 |
Args:
|
| 80 |
+
auth: Either an OAuthProvider or TokenVerifier for authentication
|
| 81 |
|
| 82 |
Returns:
|
| 83 |
Tuple of (middleware, auth_routes, required_scopes)
|
| 84 |
"""
|
| 85 |
+
middleware: list[Middleware] = [
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
Middleware(
|
| 87 |
AuthenticationMiddleware,
|
| 88 |
+
backend=BearerAuthBackend(cast(TokenVerifierProtocol, auth)),
|
| 89 |
),
|
| 90 |
Middleware(AuthContextMiddleware),
|
| 91 |
]
|
| 92 |
|
| 93 |
+
auth_routes: list[BaseRoute] = []
|
| 94 |
+
required_scopes: list[str] = []
|
| 95 |
|
| 96 |
+
# Handle TokenVerifier vs OAuthProvider
|
| 97 |
+
# Check if it's an OAuthProvider by looking for issuer_url attribute
|
| 98 |
+
if hasattr(auth, "issuer_url"):
|
| 99 |
+
# OAuthProvider: create auth routes and get required scopes
|
| 100 |
+
# We know this is an OAuthProvider because it has issuer_url
|
| 101 |
+
auth_routes = list(
|
| 102 |
+
create_auth_routes(
|
| 103 |
+
provider=auth, # type: ignore[arg-type]
|
| 104 |
+
issuer_url=auth.issuer_url, # type: ignore[attr-defined]
|
| 105 |
+
service_documentation_url=auth.service_documentation_url, # type: ignore[attr-defined]
|
| 106 |
+
client_registration_options=auth.client_registration_options, # type: ignore[attr-defined]
|
| 107 |
+
revocation_options=auth.revocation_options, # type: ignore[attr-defined]
|
| 108 |
+
)
|
| 109 |
)
|
| 110 |
+
required_scopes = auth.required_scopes or [] # type: ignore[attr-defined]
|
| 111 |
+
else:
|
| 112 |
+
# TokenVerifier: no auth routes but may have required scopes
|
| 113 |
+
required_scopes = getattr(auth, "required_scopes", None) or []
|
| 114 |
|
| 115 |
return middleware, auth_routes, required_scopes
|
| 116 |
|
|
|
|
| 147 |
server: FastMCP[LifespanResultT],
|
| 148 |
message_path: str,
|
| 149 |
sse_path: str,
|
| 150 |
+
auth: OAuthProvider | TokenVerifier | None = None,
|
| 151 |
debug: bool = False,
|
| 152 |
routes: list[BaseRoute] | None = None,
|
| 153 |
middleware: list[Middleware] | None = None,
|
|
|
|
| 158 |
server: The FastMCP server instance
|
| 159 |
message_path: Path for SSE messages
|
| 160 |
sse_path: Path for SSE connections
|
| 161 |
+
auth: Optional authentication provider (OAuthProvider or TokenVerifier)
|
| 162 |
debug: Whether to enable debug mode
|
| 163 |
routes: Optional list of custom routes
|
| 164 |
middleware: Optional list of middleware
|
|
|
|
| 183 |
return Response()
|
| 184 |
|
| 185 |
# Get auth middleware and routes
|
|
|
|
|
|
|
| 186 |
if auth:
|
| 187 |
auth_middleware, auth_routes, required_scopes = (
|
| 188 |
setup_auth_middleware_and_routes(auth)
|
|
|
|
| 190 |
|
| 191 |
server_routes.extend(auth_routes)
|
| 192 |
server_middleware.extend(auth_middleware)
|
| 193 |
+
|
| 194 |
+
# Determine resource_metadata_url for TokenVerifier
|
| 195 |
+
resource_metadata_url = None
|
| 196 |
+
if isinstance(auth, TokenVerifier) and auth.resource_server_url:
|
| 197 |
+
# Add .well-known path for RFC 9728 compliance
|
| 198 |
+
resource_metadata_url = AnyHttpUrl(
|
| 199 |
+
str(auth.resource_server_url).rstrip("/")
|
| 200 |
+
+ "/.well-known/oauth-protected-resource"
|
| 201 |
+
)
|
| 202 |
+
|
| 203 |
# Auth is enabled, wrap endpoints with RequireAuthMiddleware
|
| 204 |
server_routes.append(
|
| 205 |
Route(
|
| 206 |
sse_path,
|
| 207 |
+
endpoint=RequireAuthMiddleware(
|
| 208 |
+
handle_sse, required_scopes, resource_metadata_url
|
| 209 |
+
),
|
| 210 |
methods=["GET"],
|
| 211 |
)
|
| 212 |
)
|
| 213 |
server_routes.append(
|
| 214 |
Mount(
|
| 215 |
message_path,
|
| 216 |
+
app=RequireAuthMiddleware(
|
| 217 |
+
sse.handle_post_message, required_scopes, resource_metadata_url
|
| 218 |
+
),
|
| 219 |
)
|
| 220 |
)
|
| 221 |
else:
|
|
|
|
| 263 |
server: FastMCP[LifespanResultT],
|
| 264 |
streamable_http_path: str,
|
| 265 |
event_store: EventStore | None = None,
|
| 266 |
+
auth: OAuthProvider | TokenVerifier | None = None,
|
| 267 |
json_response: bool = False,
|
| 268 |
stateless_http: bool = False,
|
| 269 |
debug: bool = False,
|
|
|
|
| 276 |
server: The FastMCP server instance
|
| 277 |
streamable_http_path: Path for StreamableHTTP connections
|
| 278 |
event_store: Optional event store for session management
|
| 279 |
+
auth: Optional authentication provider (OAuthProvider or TokenVerifier)
|
| 280 |
json_response: Whether to use JSON response format
|
| 281 |
stateless_http: Whether to use stateless mode (new transport per request)
|
| 282 |
debug: Whether to enable debug mode
|
|
|
|
| 329 |
if auth:
|
| 330 |
resource_metadata_url = None
|
| 331 |
|
| 332 |
+
if isinstance(auth, TokenVerifier) and auth.resource_server_url:
|
| 333 |
resource_metadata_url = AnyHttpUrl(
|
| 334 |
str(auth.resource_server_url).rstrip("/")
|
| 335 |
+ "/.well-known/oauth-protected-resource"
|
|
|
|
| 342 |
server_routes.extend(auth_routes)
|
| 343 |
server_middleware.extend(auth_middleware)
|
| 344 |
|
| 345 |
+
# Determine resource_metadata_url for TokenVerifier
|
| 346 |
+
resource_metadata_url = None
|
| 347 |
+
if isinstance(auth, TokenVerifier) and auth.resource_server_url:
|
| 348 |
+
# Add .well-known path for RFC 9728 compliance
|
| 349 |
+
resource_metadata_url = AnyHttpUrl(
|
| 350 |
+
str(auth.resource_server_url).rstrip("/")
|
| 351 |
+
+ "/.well-known/oauth-protected-resource"
|
| 352 |
+
)
|
| 353 |
+
|
| 354 |
# Auth is enabled, wrap endpoint with RequireAuthMiddleware
|
| 355 |
server_routes.append(
|
| 356 |
Mount(
|
src/fastmcp/server/server.py
CHANGED
|
@@ -50,8 +50,8 @@ from fastmcp.prompts import Prompt, PromptManager
|
|
| 50 |
from fastmcp.prompts.prompt import FunctionPrompt
|
| 51 |
from fastmcp.resources import Resource, ResourceManager
|
| 52 |
from fastmcp.resources.template import ResourceTemplate
|
| 53 |
-
from fastmcp.server.auth.auth import OAuthProvider
|
| 54 |
-
from fastmcp.server.auth.
|
| 55 |
from fastmcp.server.http import (
|
| 56 |
StarletteWithLifespan,
|
| 57 |
create_sse_app,
|
|
@@ -133,7 +133,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 133 |
instructions: str | None = None,
|
| 134 |
*,
|
| 135 |
version: str | None = None,
|
| 136 |
-
auth: OAuthProvider | None = None,
|
| 137 |
middleware: list[Middleware] | None = None,
|
| 138 |
lifespan: (
|
| 139 |
Callable[
|
|
@@ -205,8 +205,9 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 205 |
lifespan=_lifespan_wrapper(self, lifespan),
|
| 206 |
)
|
| 207 |
|
| 208 |
-
if auth is None and fastmcp.settings.default_auth_provider == "
|
| 209 |
-
auth =
|
|
|
|
| 210 |
self.auth = auth
|
| 211 |
|
| 212 |
if tools:
|
|
|
|
| 50 |
from fastmcp.prompts.prompt import FunctionPrompt
|
| 51 |
from fastmcp.resources import Resource, ResourceManager
|
| 52 |
from fastmcp.resources.template import ResourceTemplate
|
| 53 |
+
from fastmcp.server.auth.auth import OAuthProvider, TokenVerifier
|
| 54 |
+
from fastmcp.server.auth.verifiers import EnvJWTVerifier
|
| 55 |
from fastmcp.server.http import (
|
| 56 |
StarletteWithLifespan,
|
| 57 |
create_sse_app,
|
|
|
|
| 133 |
instructions: str | None = None,
|
| 134 |
*,
|
| 135 |
version: str | None = None,
|
| 136 |
+
auth: OAuthProvider | TokenVerifier | None = None,
|
| 137 |
middleware: list[Middleware] | None = None,
|
| 138 |
lifespan: (
|
| 139 |
Callable[
|
|
|
|
| 205 |
lifespan=_lifespan_wrapper(self, lifespan),
|
| 206 |
)
|
| 207 |
|
| 208 |
+
if auth is None and fastmcp.settings.default_auth_provider == "jwt-env":
|
| 209 |
+
auth = EnvJWTVerifier()
|
| 210 |
+
|
| 211 |
self.auth = auth
|
| 212 |
|
| 213 |
if tools:
|
src/fastmcp/settings.py
CHANGED
|
@@ -260,7 +260,7 @@ class Settings(BaseSettings):
|
|
| 260 |
|
| 261 |
# Auth settings
|
| 262 |
default_auth_provider: Annotated[
|
| 263 |
-
Literal["
|
| 264 |
Field(
|
| 265 |
description=inspect.cleandoc(
|
| 266 |
"""
|
|
|
|
| 260 |
|
| 261 |
# Auth settings
|
| 262 |
default_auth_provider: Annotated[
|
| 263 |
+
Literal["jwt-env"] | None,
|
| 264 |
Field(
|
| 265 |
description=inspect.cleandoc(
|
| 266 |
"""
|
tests/auth/providers/test_token_verifier.py
CHANGED
|
@@ -3,12 +3,12 @@
|
|
| 3 |
import pytest
|
| 4 |
from mcp.server.auth.provider import AccessToken
|
| 5 |
|
| 6 |
-
from fastmcp.server.auth.providers.bearer import BearerAuthProvider, RSAKeyPair
|
| 7 |
from fastmcp.server.auth.providers.in_memory import InMemoryOAuthProvider
|
|
|
|
| 8 |
|
| 9 |
|
| 10 |
-
class
|
| 11 |
-
"""Test that
|
| 12 |
|
| 13 |
@pytest.fixture
|
| 14 |
def rsa_key_pair(self) -> RSAKeyPair:
|
|
@@ -16,9 +16,9 @@ class TestBearerAuthProviderTokenVerifier:
|
|
| 16 |
return RSAKeyPair.generate()
|
| 17 |
|
| 18 |
@pytest.fixture
|
| 19 |
-
def
|
| 20 |
-
"""Create
|
| 21 |
-
return
|
| 22 |
public_key=rsa_key_pair.public_key,
|
| 23 |
issuer="https://test.example.com",
|
| 24 |
audience="https://api.example.com",
|
|
@@ -45,10 +45,10 @@ class TestBearerAuthProviderTokenVerifier:
|
|
| 45 |
)
|
| 46 |
|
| 47 |
async def test_verify_token_with_valid_token(
|
| 48 |
-
self,
|
| 49 |
):
|
| 50 |
"""Test that verify_token returns AccessToken for valid token."""
|
| 51 |
-
result = await
|
| 52 |
|
| 53 |
assert result is not None
|
| 54 |
assert isinstance(result, AccessToken)
|
|
@@ -58,33 +58,29 @@ class TestBearerAuthProviderTokenVerifier:
|
|
| 58 |
assert "write" in result.scopes
|
| 59 |
|
| 60 |
async def test_verify_token_with_expired_token(
|
| 61 |
-
self,
|
| 62 |
):
|
| 63 |
"""Test that verify_token returns None for expired token."""
|
| 64 |
-
result = await
|
| 65 |
assert result is None
|
| 66 |
|
| 67 |
-
async def test_verify_token_with_invalid_token(
|
| 68 |
-
self, bearer_provider: BearerAuthProvider
|
| 69 |
-
):
|
| 70 |
"""Test that verify_token returns None for invalid token."""
|
| 71 |
-
result = await
|
| 72 |
assert result is None
|
| 73 |
|
| 74 |
-
async def test_verify_token_with_malformed_token(
|
| 75 |
-
self, bearer_provider: BearerAuthProvider
|
| 76 |
-
):
|
| 77 |
"""Test that verify_token returns None for malformed token."""
|
| 78 |
-
result = await
|
| 79 |
assert result is None
|
| 80 |
|
| 81 |
async def test_verify_token_delegation_to_load_access_token(
|
| 82 |
-
self,
|
| 83 |
):
|
| 84 |
"""Test that verify_token delegates to load_access_token."""
|
| 85 |
# Both methods should return the same result
|
| 86 |
-
verify_result = await
|
| 87 |
-
load_result = await
|
| 88 |
|
| 89 |
assert verify_result == load_result
|
| 90 |
if verify_result is not None and load_result is not None:
|
|
@@ -162,9 +158,9 @@ class TestTokenVerifierProtocolCompliance:
|
|
| 162 |
"""Test that our providers properly implement the TokenVerifier protocol."""
|
| 163 |
|
| 164 |
async def test_bearer_provider_implements_protocol(self):
|
| 165 |
-
"""Test that
|
| 166 |
key_pair = RSAKeyPair.generate()
|
| 167 |
-
provider =
|
| 168 |
|
| 169 |
# Should have the required method for TokenVerifier protocol
|
| 170 |
assert hasattr(provider, "verify_token")
|
|
|
|
| 3 |
import pytest
|
| 4 |
from mcp.server.auth.provider import AccessToken
|
| 5 |
|
|
|
|
| 6 |
from fastmcp.server.auth.providers.in_memory import InMemoryOAuthProvider
|
| 7 |
+
from fastmcp.server.auth.verifiers import JWTVerifier, RSAKeyPair
|
| 8 |
|
| 9 |
|
| 10 |
+
class TestJWTVerifierTokenVerifier:
|
| 11 |
+
"""Test that JWTVerifier implements TokenVerifier protocol correctly."""
|
| 12 |
|
| 13 |
@pytest.fixture
|
| 14 |
def rsa_key_pair(self) -> RSAKeyPair:
|
|
|
|
| 16 |
return RSAKeyPair.generate()
|
| 17 |
|
| 18 |
@pytest.fixture
|
| 19 |
+
def jwt_verifier(self, rsa_key_pair: RSAKeyPair) -> JWTVerifier:
|
| 20 |
+
"""Create JWTVerifier for testing."""
|
| 21 |
+
return JWTVerifier(
|
| 22 |
public_key=rsa_key_pair.public_key,
|
| 23 |
issuer="https://test.example.com",
|
| 24 |
audience="https://api.example.com",
|
|
|
|
| 45 |
)
|
| 46 |
|
| 47 |
async def test_verify_token_with_valid_token(
|
| 48 |
+
self, jwt_verifier: JWTVerifier, valid_token: str
|
| 49 |
):
|
| 50 |
"""Test that verify_token returns AccessToken for valid token."""
|
| 51 |
+
result = await jwt_verifier.verify_token(valid_token)
|
| 52 |
|
| 53 |
assert result is not None
|
| 54 |
assert isinstance(result, AccessToken)
|
|
|
|
| 58 |
assert "write" in result.scopes
|
| 59 |
|
| 60 |
async def test_verify_token_with_expired_token(
|
| 61 |
+
self, jwt_verifier: JWTVerifier, expired_token: str
|
| 62 |
):
|
| 63 |
"""Test that verify_token returns None for expired token."""
|
| 64 |
+
result = await jwt_verifier.verify_token(expired_token)
|
| 65 |
assert result is None
|
| 66 |
|
| 67 |
+
async def test_verify_token_with_invalid_token(self, jwt_verifier: JWTVerifier):
|
|
|
|
|
|
|
| 68 |
"""Test that verify_token returns None for invalid token."""
|
| 69 |
+
result = await jwt_verifier.verify_token("invalid.token.here")
|
| 70 |
assert result is None
|
| 71 |
|
| 72 |
+
async def test_verify_token_with_malformed_token(self, jwt_verifier: JWTVerifier):
|
|
|
|
|
|
|
| 73 |
"""Test that verify_token returns None for malformed token."""
|
| 74 |
+
result = await jwt_verifier.verify_token("not-a-jwt")
|
| 75 |
assert result is None
|
| 76 |
|
| 77 |
async def test_verify_token_delegation_to_load_access_token(
|
| 78 |
+
self, jwt_verifier: JWTVerifier, valid_token: str
|
| 79 |
):
|
| 80 |
"""Test that verify_token delegates to load_access_token."""
|
| 81 |
# Both methods should return the same result
|
| 82 |
+
verify_result = await jwt_verifier.verify_token(valid_token)
|
| 83 |
+
load_result = await jwt_verifier.load_access_token(valid_token)
|
| 84 |
|
| 85 |
assert verify_result == load_result
|
| 86 |
if verify_result is not None and load_result is not None:
|
|
|
|
| 158 |
"""Test that our providers properly implement the TokenVerifier protocol."""
|
| 159 |
|
| 160 |
async def test_bearer_provider_implements_protocol(self):
|
| 161 |
+
"""Test that JWTVerifier can be used as TokenVerifier."""
|
| 162 |
key_pair = RSAKeyPair.generate()
|
| 163 |
+
provider = JWTVerifier(public_key=key_pair.public_key)
|
| 164 |
|
| 165 |
# Should have the required method for TokenVerifier protocol
|
| 166 |
assert hasattr(provider, "verify_token")
|
tests/auth/{providers/test_bearer_env.py → verifiers/test_env_jwt.py}
RENAMED
|
@@ -1,9 +1,8 @@
|
|
| 1 |
import pytest
|
| 2 |
-
from pydantic import
|
| 3 |
|
| 4 |
from fastmcp import FastMCP
|
| 5 |
-
from fastmcp.server.auth.
|
| 6 |
-
from fastmcp.server.auth.providers.bearer_env import EnvBearerAuthProvider
|
| 7 |
from fastmcp.settings import Settings
|
| 8 |
from fastmcp.utilities.tests import temporary_settings
|
| 9 |
|
|
@@ -12,19 +11,19 @@ def test_load_bearer_env_from_env_var(monkeypatch):
|
|
| 12 |
mcp = FastMCP()
|
| 13 |
assert mcp.auth is None
|
| 14 |
|
| 15 |
-
monkeypatch.setenv("FASTMCP_DEFAULT_AUTH_PROVIDER", "
|
| 16 |
-
monkeypatch.setenv("
|
| 17 |
|
| 18 |
with temporary_settings(**Settings().model_dump()):
|
| 19 |
mcp_with_auth = FastMCP()
|
| 20 |
-
assert isinstance(mcp_with_auth.auth,
|
| 21 |
|
| 22 |
|
| 23 |
def test_load_bearer_env_from_env_var_requires_public_key_or_jwks_uri(monkeypatch):
|
| 24 |
mcp = FastMCP()
|
| 25 |
assert mcp.auth is None
|
| 26 |
|
| 27 |
-
monkeypatch.setenv("FASTMCP_DEFAULT_AUTH_PROVIDER", "
|
| 28 |
|
| 29 |
with temporary_settings(**Settings().model_dump()):
|
| 30 |
with pytest.raises(
|
|
@@ -34,26 +33,25 @@ def test_load_bearer_env_from_env_var_requires_public_key_or_jwks_uri(monkeypatc
|
|
| 34 |
|
| 35 |
|
| 36 |
def test_configure_bearer_env_from_env_var(monkeypatch):
|
| 37 |
-
monkeypatch.setenv("FASTMCP_DEFAULT_AUTH_PROVIDER", "
|
| 38 |
-
monkeypatch.setenv("
|
| 39 |
-
monkeypatch.setenv("
|
| 40 |
-
monkeypatch.setenv("
|
| 41 |
monkeypatch.setenv(
|
| 42 |
-
"
|
| 43 |
)
|
| 44 |
|
| 45 |
with temporary_settings(**Settings().model_dump()):
|
| 46 |
mcp = FastMCP()
|
| 47 |
-
assert isinstance(mcp.auth,
|
| 48 |
assert mcp.auth.public_key == "test-public-key"
|
| 49 |
-
assert mcp.auth.issuer_url == AnyHttpUrl("http://test-issuer")
|
| 50 |
assert mcp.auth.audience == "test-audience"
|
| 51 |
assert mcp.auth.required_scopes == ["test-scope1", "test-scope2"]
|
| 52 |
|
| 53 |
|
| 54 |
def test_list_of_scopes_must_be_a_list(monkeypatch):
|
| 55 |
-
monkeypatch.setenv("FASTMCP_DEFAULT_AUTH_PROVIDER", "
|
| 56 |
-
monkeypatch.setenv("
|
| 57 |
|
| 58 |
with temporary_settings(**Settings().model_dump()):
|
| 59 |
with pytest.raises(ValidationError, match="Input should be a valid list"):
|
|
@@ -61,19 +59,19 @@ def test_list_of_scopes_must_be_a_list(monkeypatch):
|
|
| 61 |
|
| 62 |
|
| 63 |
def test_configure_bearer_env_jwks_uri_from_env_var(monkeypatch):
|
| 64 |
-
monkeypatch.setenv("FASTMCP_DEFAULT_AUTH_PROVIDER", "
|
| 65 |
-
monkeypatch.setenv("
|
| 66 |
|
| 67 |
with temporary_settings(**Settings().model_dump()):
|
| 68 |
mcp = FastMCP()
|
| 69 |
-
assert isinstance(mcp.auth,
|
| 70 |
assert mcp.auth.jwks_uri == "test-jwks-uri"
|
| 71 |
|
| 72 |
|
| 73 |
def test_configure_bearer_env_public_key_and_jwks_uri_error(monkeypatch):
|
| 74 |
-
monkeypatch.setenv("FASTMCP_DEFAULT_AUTH_PROVIDER", "
|
| 75 |
-
monkeypatch.setenv("
|
| 76 |
-
monkeypatch.setenv("
|
| 77 |
|
| 78 |
with temporary_settings(**Settings().model_dump()):
|
| 79 |
with pytest.raises(ValueError, match="Provide either public_key or jwks_uri"):
|
|
@@ -81,11 +79,11 @@ def test_configure_bearer_env_public_key_and_jwks_uri_error(monkeypatch):
|
|
| 81 |
|
| 82 |
|
| 83 |
def test_provided_auth_takes_precedence_over_env_vars(monkeypatch):
|
| 84 |
-
monkeypatch.setenv("FASTMCP_DEFAULT_AUTH_PROVIDER", "
|
| 85 |
-
monkeypatch.setenv("
|
| 86 |
|
| 87 |
with temporary_settings(**Settings().model_dump()):
|
| 88 |
-
mcp = FastMCP(auth=
|
| 89 |
-
assert isinstance(mcp.auth,
|
| 90 |
-
assert not isinstance(mcp.auth,
|
| 91 |
assert mcp.auth.public_key == "test-public-key-2"
|
|
|
|
| 1 |
import pytest
|
| 2 |
+
from pydantic import ValidationError
|
| 3 |
|
| 4 |
from fastmcp import FastMCP
|
| 5 |
+
from fastmcp.server.auth.verifiers import EnvJWTVerifier, JWTVerifier
|
|
|
|
| 6 |
from fastmcp.settings import Settings
|
| 7 |
from fastmcp.utilities.tests import temporary_settings
|
| 8 |
|
|
|
|
| 11 |
mcp = FastMCP()
|
| 12 |
assert mcp.auth is None
|
| 13 |
|
| 14 |
+
monkeypatch.setenv("FASTMCP_DEFAULT_AUTH_PROVIDER", "jwt-env")
|
| 15 |
+
monkeypatch.setenv("FASTMCP_AUTH_JWT_PUBLIC_KEY", "test-public-key")
|
| 16 |
|
| 17 |
with temporary_settings(**Settings().model_dump()):
|
| 18 |
mcp_with_auth = FastMCP()
|
| 19 |
+
assert isinstance(mcp_with_auth.auth, EnvJWTVerifier)
|
| 20 |
|
| 21 |
|
| 22 |
def test_load_bearer_env_from_env_var_requires_public_key_or_jwks_uri(monkeypatch):
|
| 23 |
mcp = FastMCP()
|
| 24 |
assert mcp.auth is None
|
| 25 |
|
| 26 |
+
monkeypatch.setenv("FASTMCP_DEFAULT_AUTH_PROVIDER", "jwt-env")
|
| 27 |
|
| 28 |
with temporary_settings(**Settings().model_dump()):
|
| 29 |
with pytest.raises(
|
|
|
|
| 33 |
|
| 34 |
|
| 35 |
def test_configure_bearer_env_from_env_var(monkeypatch):
|
| 36 |
+
monkeypatch.setenv("FASTMCP_DEFAULT_AUTH_PROVIDER", "jwt-env")
|
| 37 |
+
monkeypatch.setenv("FASTMCP_AUTH_JWT_PUBLIC_KEY", "test-public-key")
|
| 38 |
+
monkeypatch.setenv("FASTMCP_AUTH_JWT_ISSUER", "http://test-issuer")
|
| 39 |
+
monkeypatch.setenv("FASTMCP_AUTH_JWT_AUDIENCE", "test-audience")
|
| 40 |
monkeypatch.setenv(
|
| 41 |
+
"FASTMCP_AUTH_JWT_REQUIRED_SCOPES", '["test-scope1", "test-scope2"]'
|
| 42 |
)
|
| 43 |
|
| 44 |
with temporary_settings(**Settings().model_dump()):
|
| 45 |
mcp = FastMCP()
|
| 46 |
+
assert isinstance(mcp.auth, EnvJWTVerifier)
|
| 47 |
assert mcp.auth.public_key == "test-public-key"
|
|
|
|
| 48 |
assert mcp.auth.audience == "test-audience"
|
| 49 |
assert mcp.auth.required_scopes == ["test-scope1", "test-scope2"]
|
| 50 |
|
| 51 |
|
| 52 |
def test_list_of_scopes_must_be_a_list(monkeypatch):
|
| 53 |
+
monkeypatch.setenv("FASTMCP_DEFAULT_AUTH_PROVIDER", "jwt-env")
|
| 54 |
+
monkeypatch.setenv("FASTMCP_AUTH_JWT_REQUIRED_SCOPES", "test-scope1")
|
| 55 |
|
| 56 |
with temporary_settings(**Settings().model_dump()):
|
| 57 |
with pytest.raises(ValidationError, match="Input should be a valid list"):
|
|
|
|
| 59 |
|
| 60 |
|
| 61 |
def test_configure_bearer_env_jwks_uri_from_env_var(monkeypatch):
|
| 62 |
+
monkeypatch.setenv("FASTMCP_DEFAULT_AUTH_PROVIDER", "jwt-env")
|
| 63 |
+
monkeypatch.setenv("FASTMCP_AUTH_JWT_JWKS_URI", "test-jwks-uri")
|
| 64 |
|
| 65 |
with temporary_settings(**Settings().model_dump()):
|
| 66 |
mcp = FastMCP()
|
| 67 |
+
assert isinstance(mcp.auth, EnvJWTVerifier)
|
| 68 |
assert mcp.auth.jwks_uri == "test-jwks-uri"
|
| 69 |
|
| 70 |
|
| 71 |
def test_configure_bearer_env_public_key_and_jwks_uri_error(monkeypatch):
|
| 72 |
+
monkeypatch.setenv("FASTMCP_DEFAULT_AUTH_PROVIDER", "jwt-env")
|
| 73 |
+
monkeypatch.setenv("FASTMCP_AUTH_JWT_PUBLIC_KEY", "test-public-key")
|
| 74 |
+
monkeypatch.setenv("FASTMCP_AUTH_JWT_JWKS_URI", "test-jwks-uri")
|
| 75 |
|
| 76 |
with temporary_settings(**Settings().model_dump()):
|
| 77 |
with pytest.raises(ValueError, match="Provide either public_key or jwks_uri"):
|
|
|
|
| 79 |
|
| 80 |
|
| 81 |
def test_provided_auth_takes_precedence_over_env_vars(monkeypatch):
|
| 82 |
+
monkeypatch.setenv("FASTMCP_DEFAULT_AUTH_PROVIDER", "jwt-env")
|
| 83 |
+
monkeypatch.setenv("FASTMCP_AUTH_JWT_PUBLIC_KEY", "test-public-key")
|
| 84 |
|
| 85 |
with temporary_settings(**Settings().model_dump()):
|
| 86 |
+
mcp = FastMCP(auth=JWTVerifier(public_key="test-public-key-2"))
|
| 87 |
+
assert isinstance(mcp.auth, JWTVerifier)
|
| 88 |
+
assert not isinstance(mcp.auth, EnvJWTVerifier)
|
| 89 |
assert mcp.auth.public_key == "test-public-key-2"
|
tests/auth/{providers/test_bearer.py → verifiers/test_jwt_verifier.py}
RENAMED
|
@@ -7,12 +7,7 @@ from pytest_httpx import HTTPXMock
|
|
| 7 |
|
| 8 |
from fastmcp import Client, FastMCP
|
| 9 |
from fastmcp.client.auth.bearer import BearerAuth
|
| 10 |
-
from fastmcp.server.auth.
|
| 11 |
-
BearerAuthProvider,
|
| 12 |
-
JWKData,
|
| 13 |
-
JWKSData,
|
| 14 |
-
RSAKeyPair,
|
| 15 |
-
)
|
| 16 |
from fastmcp.utilities.tests import run_server_in_process
|
| 17 |
|
| 18 |
|
|
@@ -31,8 +26,8 @@ def bearer_token(rsa_key_pair: RSAKeyPair) -> str:
|
|
| 31 |
|
| 32 |
|
| 33 |
@pytest.fixture
|
| 34 |
-
def bearer_provider(rsa_key_pair: RSAKeyPair) ->
|
| 35 |
-
return
|
| 36 |
public_key=rsa_key_pair.public_key,
|
| 37 |
issuer="https://test.example.com",
|
| 38 |
audience="https://api.example.com",
|
|
@@ -47,7 +42,7 @@ def run_mcp_server(
|
|
| 47 |
run_kwargs: dict[str, Any] | None = None,
|
| 48 |
) -> None:
|
| 49 |
mcp = FastMCP(
|
| 50 |
-
auth=
|
| 51 |
public_key=public_key,
|
| 52 |
**auth_kwargs or {},
|
| 53 |
)
|
|
@@ -113,9 +108,9 @@ class TestBearerTokenJWKS:
|
|
| 113 |
"""Tests for JWKS URI functionality."""
|
| 114 |
|
| 115 |
@pytest.fixture
|
| 116 |
-
def jwks_provider(self, rsa_key_pair: RSAKeyPair) ->
|
| 117 |
"""Provider configured with JWKS URI."""
|
| 118 |
-
return
|
| 119 |
jwks_uri="https://test.example.com/.well-known/jwks.json",
|
| 120 |
issuer="https://test.example.com",
|
| 121 |
audience="https://api.example.com",
|
|
@@ -137,7 +132,7 @@ class TestBearerTokenJWKS:
|
|
| 137 |
async def test_jwks_token_validation(
|
| 138 |
self,
|
| 139 |
rsa_key_pair: RSAKeyPair,
|
| 140 |
-
jwks_provider:
|
| 141 |
mock_jwks_data: JWKSData,
|
| 142 |
httpx_mock: HTTPXMock,
|
| 143 |
):
|
|
@@ -159,7 +154,7 @@ class TestBearerTokenJWKS:
|
|
| 159 |
async def test_jwks_token_validation_with_invalid_key(
|
| 160 |
self,
|
| 161 |
rsa_key_pair: RSAKeyPair,
|
| 162 |
-
jwks_provider:
|
| 163 |
mock_jwks_data: JWKSData,
|
| 164 |
httpx_mock: HTTPXMock,
|
| 165 |
):
|
|
@@ -179,7 +174,7 @@ class TestBearerTokenJWKS:
|
|
| 179 |
async def test_jwks_token_validation_with_kid(
|
| 180 |
self,
|
| 181 |
rsa_key_pair: RSAKeyPair,
|
| 182 |
-
jwks_provider:
|
| 183 |
mock_jwks_data: JWKSData,
|
| 184 |
httpx_mock: HTTPXMock,
|
| 185 |
):
|
|
@@ -202,7 +197,7 @@ class TestBearerTokenJWKS:
|
|
| 202 |
async def test_jwks_token_validation_with_kid_and_no_kid_in_token(
|
| 203 |
self,
|
| 204 |
rsa_key_pair: RSAKeyPair,
|
| 205 |
-
jwks_provider:
|
| 206 |
mock_jwks_data: JWKSData,
|
| 207 |
httpx_mock: HTTPXMock,
|
| 208 |
):
|
|
@@ -224,7 +219,7 @@ class TestBearerTokenJWKS:
|
|
| 224 |
async def test_jwks_token_validation_with_no_kid_and_kid_in_jwks(
|
| 225 |
self,
|
| 226 |
rsa_key_pair: RSAKeyPair,
|
| 227 |
-
jwks_provider:
|
| 228 |
mock_jwks_data: JWKSData,
|
| 229 |
httpx_mock: HTTPXMock,
|
| 230 |
):
|
|
@@ -246,7 +241,7 @@ class TestBearerTokenJWKS:
|
|
| 246 |
async def test_jwks_token_validation_with_kid_mismatch(
|
| 247 |
self,
|
| 248 |
rsa_key_pair: RSAKeyPair,
|
| 249 |
-
jwks_provider:
|
| 250 |
mock_jwks_data: JWKSData,
|
| 251 |
httpx_mock: HTTPXMock,
|
| 252 |
):
|
|
@@ -268,7 +263,7 @@ class TestBearerTokenJWKS:
|
|
| 268 |
async def test_jwks_token_validation_with_multiple_keys_and_no_kid_in_token(
|
| 269 |
self,
|
| 270 |
rsa_key_pair: RSAKeyPair,
|
| 271 |
-
jwks_provider:
|
| 272 |
mock_jwks_data: JWKSData,
|
| 273 |
httpx_mock: HTTPXMock,
|
| 274 |
):
|
|
@@ -300,7 +295,7 @@ class TestBearerTokenJWKS:
|
|
| 300 |
class TestBearerToken:
|
| 301 |
def test_initialization_with_public_key(self, rsa_key_pair: RSAKeyPair):
|
| 302 |
"""Test provider initialization with public key."""
|
| 303 |
-
provider =
|
| 304 |
public_key=rsa_key_pair.public_key, issuer="https://test.example.com"
|
| 305 |
)
|
| 306 |
|
|
@@ -310,7 +305,7 @@ class TestBearerToken:
|
|
| 310 |
|
| 311 |
def test_initialization_with_jwks_uri(self):
|
| 312 |
"""Test provider initialization with JWKS URI."""
|
| 313 |
-
provider =
|
| 314 |
jwks_uri="https://test.example.com/.well-known/jwks.json",
|
| 315 |
issuer="https://test.example.com",
|
| 316 |
)
|
|
@@ -324,21 +319,21 @@ class TestBearerToken:
|
|
| 324 |
with pytest.raises(
|
| 325 |
ValueError, match="Either public_key or jwks_uri must be provided"
|
| 326 |
):
|
| 327 |
-
|
| 328 |
|
| 329 |
def test_initialization_rejects_both_key_and_uri(self, rsa_key_pair: RSAKeyPair):
|
| 330 |
"""Test that both public_key and jwks_uri cannot be provided."""
|
| 331 |
with pytest.raises(
|
| 332 |
ValueError, match="Provide either public_key or jwks_uri, not both"
|
| 333 |
):
|
| 334 |
-
|
| 335 |
public_key=rsa_key_pair.public_key,
|
| 336 |
jwks_uri="https://test.example.com/.well-known/jwks.json",
|
| 337 |
issuer="https://test.example.com",
|
| 338 |
)
|
| 339 |
|
| 340 |
async def test_valid_token_validation(
|
| 341 |
-
self, rsa_key_pair: RSAKeyPair, bearer_provider:
|
| 342 |
):
|
| 343 |
"""Test validation of a valid token."""
|
| 344 |
token = rsa_key_pair.create_token(
|
|
@@ -357,7 +352,7 @@ class TestBearerToken:
|
|
| 357 |
assert access_token.expires_at is not None
|
| 358 |
|
| 359 |
async def test_expired_token_rejection(
|
| 360 |
-
self, rsa_key_pair: RSAKeyPair, bearer_provider:
|
| 361 |
):
|
| 362 |
"""Test rejection of expired tokens."""
|
| 363 |
token = rsa_key_pair.create_token(
|
|
@@ -371,7 +366,7 @@ class TestBearerToken:
|
|
| 371 |
assert access_token is None
|
| 372 |
|
| 373 |
async def test_invalid_issuer_rejection(
|
| 374 |
-
self, rsa_key_pair: RSAKeyPair, bearer_provider:
|
| 375 |
):
|
| 376 |
"""Test rejection of tokens with invalid issuer."""
|
| 377 |
token = rsa_key_pair.create_token(
|
|
@@ -384,7 +379,7 @@ class TestBearerToken:
|
|
| 384 |
assert access_token is None
|
| 385 |
|
| 386 |
async def test_invalid_audience_rejection(
|
| 387 |
-
self, rsa_key_pair: RSAKeyPair, bearer_provider:
|
| 388 |
):
|
| 389 |
"""Test rejection of tokens with invalid audience."""
|
| 390 |
token = rsa_key_pair.create_token(
|
|
@@ -398,7 +393,7 @@ class TestBearerToken:
|
|
| 398 |
|
| 399 |
async def test_no_issuer_validation_when_none(self, rsa_key_pair: RSAKeyPair):
|
| 400 |
"""Test that issuer validation is skipped when provider has no issuer configured."""
|
| 401 |
-
provider =
|
| 402 |
public_key=rsa_key_pair.public_key,
|
| 403 |
issuer=None, # No issuer validation
|
| 404 |
)
|
|
@@ -412,7 +407,7 @@ class TestBearerToken:
|
|
| 412 |
|
| 413 |
async def test_no_audience_validation_when_none(self, rsa_key_pair: RSAKeyPair):
|
| 414 |
"""Test that audience validation is skipped when provider has no audience configured."""
|
| 415 |
-
provider =
|
| 416 |
public_key=rsa_key_pair.public_key,
|
| 417 |
issuer="https://test.example.com",
|
| 418 |
audience=None, # No audience validation
|
|
@@ -429,7 +424,7 @@ class TestBearerToken:
|
|
| 429 |
|
| 430 |
async def test_multiple_audiences_validation(self, rsa_key_pair: RSAKeyPair):
|
| 431 |
"""Test validation with multiple audiences in token."""
|
| 432 |
-
provider =
|
| 433 |
public_key=rsa_key_pair.public_key,
|
| 434 |
issuer="https://test.example.com",
|
| 435 |
audience="https://api.example.com",
|
|
@@ -450,7 +445,7 @@ class TestBearerToken:
|
|
| 450 |
self, rsa_key_pair: RSAKeyPair
|
| 451 |
):
|
| 452 |
"""Test provider configured with multiple expected audiences."""
|
| 453 |
-
provider =
|
| 454 |
public_key=rsa_key_pair.public_key,
|
| 455 |
issuer="https://test.example.com",
|
| 456 |
audience=["https://api.example.com", "https://other-api.example.com"],
|
|
@@ -486,7 +481,7 @@ class TestBearerToken:
|
|
| 486 |
assert access_token3 is None
|
| 487 |
|
| 488 |
async def test_scope_extraction_string(
|
| 489 |
-
self, rsa_key_pair: RSAKeyPair, bearer_provider:
|
| 490 |
):
|
| 491 |
"""Test scope extraction from space-separated string."""
|
| 492 |
token = rsa_key_pair.create_token(
|
|
@@ -502,7 +497,7 @@ class TestBearerToken:
|
|
| 502 |
assert set(access_token.scopes) == {"read", "write", "admin"}
|
| 503 |
|
| 504 |
async def test_scope_extraction_list(
|
| 505 |
-
self, rsa_key_pair: RSAKeyPair, bearer_provider:
|
| 506 |
):
|
| 507 |
"""Test scope extraction from list format."""
|
| 508 |
token = rsa_key_pair.create_token(
|
|
@@ -518,7 +513,7 @@ class TestBearerToken:
|
|
| 518 |
assert set(access_token.scopes) == {"read", "write"}
|
| 519 |
|
| 520 |
async def test_no_scopes(
|
| 521 |
-
self, rsa_key_pair: RSAKeyPair, bearer_provider:
|
| 522 |
):
|
| 523 |
"""Test token with no scopes."""
|
| 524 |
token = rsa_key_pair.create_token(
|
|
@@ -534,7 +529,7 @@ class TestBearerToken:
|
|
| 534 |
assert access_token.scopes == []
|
| 535 |
|
| 536 |
async def test_scp_claim_extraction_string(
|
| 537 |
-
self, rsa_key_pair: RSAKeyPair, bearer_provider:
|
| 538 |
):
|
| 539 |
"""Test scope extraction from 'scp' claim with space-separated string."""
|
| 540 |
token = rsa_key_pair.create_token(
|
|
@@ -550,7 +545,7 @@ class TestBearerToken:
|
|
| 550 |
assert set(access_token.scopes) == {"read", "write", "admin"}
|
| 551 |
|
| 552 |
async def test_scp_claim_extraction_list(
|
| 553 |
-
self, rsa_key_pair: RSAKeyPair, bearer_provider:
|
| 554 |
):
|
| 555 |
"""Test scope extraction from 'scp' claim with list format."""
|
| 556 |
token = rsa_key_pair.create_token(
|
|
@@ -568,7 +563,7 @@ class TestBearerToken:
|
|
| 568 |
assert set(access_token.scopes) == {"read", "write", "admin"}
|
| 569 |
|
| 570 |
async def test_scope_precedence_over_scp(
|
| 571 |
-
self, rsa_key_pair: RSAKeyPair, bearer_provider:
|
| 572 |
):
|
| 573 |
"""Test that 'scope' claim takes precedence over 'scp' claim when both are present."""
|
| 574 |
token = rsa_key_pair.create_token(
|
|
@@ -586,7 +581,7 @@ class TestBearerToken:
|
|
| 586 |
assert access_token is not None
|
| 587 |
assert set(access_token.scopes) == {"read", "write"} # Only 'scope' claim used
|
| 588 |
|
| 589 |
-
async def test_malformed_token_rejection(self, bearer_provider:
|
| 590 |
"""Test rejection of malformed tokens."""
|
| 591 |
malformed_tokens = [
|
| 592 |
"not.a.jwt",
|
|
@@ -601,7 +596,7 @@ class TestBearerToken:
|
|
| 601 |
assert access_token is None
|
| 602 |
|
| 603 |
async def test_invalid_signature_rejection(
|
| 604 |
-
self, rsa_key_pair: RSAKeyPair, bearer_provider:
|
| 605 |
):
|
| 606 |
"""Test rejection of tokens with invalid signatures."""
|
| 607 |
# Create a token with a different key pair
|
|
@@ -616,7 +611,7 @@ class TestBearerToken:
|
|
| 616 |
assert access_token is None
|
| 617 |
|
| 618 |
async def test_client_id_fallback(
|
| 619 |
-
self, rsa_key_pair: RSAKeyPair, bearer_provider:
|
| 620 |
):
|
| 621 |
"""Test client_id extraction with fallback logic."""
|
| 622 |
# Test with explicit client_id claim
|
|
@@ -634,7 +629,7 @@ class TestBearerToken:
|
|
| 634 |
async def test_string_issuer_validation(self, rsa_key_pair: RSAKeyPair):
|
| 635 |
"""Test that string (non-URL) issuers are supported per RFC 7519."""
|
| 636 |
# Create provider with string issuer
|
| 637 |
-
provider =
|
| 638 |
public_key=rsa_key_pair.public_key,
|
| 639 |
issuer="my-service", # String issuer, not a URL
|
| 640 |
)
|
|
@@ -652,7 +647,7 @@ class TestBearerToken:
|
|
| 652 |
async def test_string_issuer_mismatch_rejection(self, rsa_key_pair: RSAKeyPair):
|
| 653 |
"""Test that mismatched string issuers are rejected."""
|
| 654 |
# Create provider with one string issuer
|
| 655 |
-
provider =
|
| 656 |
public_key=rsa_key_pair.public_key,
|
| 657 |
issuer="my-service",
|
| 658 |
)
|
|
@@ -669,7 +664,7 @@ class TestBearerToken:
|
|
| 669 |
async def test_url_issuer_still_works(self, rsa_key_pair: RSAKeyPair):
|
| 670 |
"""Test that URL issuers still work after the fix."""
|
| 671 |
# Create provider with URL issuer
|
| 672 |
-
provider =
|
| 673 |
public_key=rsa_key_pair.public_key,
|
| 674 |
issuer="https://my-auth-server.com", # URL issuer
|
| 675 |
)
|
|
@@ -688,9 +683,9 @@ class TestBearerToken:
|
|
| 688 |
class TestFastMCPBearerAuth:
|
| 689 |
def test_bearer_auth(self):
|
| 690 |
mcp = FastMCP(
|
| 691 |
-
auth=
|
| 692 |
)
|
| 693 |
-
assert isinstance(mcp.auth,
|
| 694 |
|
| 695 |
async def test_unauthorized_access(self, mcp_server_url: str):
|
| 696 |
with pytest.raises(httpx.HTTPStatusError) as exc_info:
|
|
@@ -755,7 +750,10 @@ class TestFastMCPBearerAuth:
|
|
| 755 |
with pytest.raises(httpx.HTTPStatusError) as exc_info:
|
| 756 |
async with Client(mcp_server_url, auth=BearerAuth(token)) as client:
|
| 757 |
tools = await client.list_tools() # noqa: F841
|
| 758 |
-
|
|
|
|
|
|
|
|
|
|
| 759 |
assert "tools" not in locals()
|
| 760 |
|
| 761 |
async def test_token_with_sufficient_scopes(
|
|
|
|
| 7 |
|
| 8 |
from fastmcp import Client, FastMCP
|
| 9 |
from fastmcp.client.auth.bearer import BearerAuth
|
| 10 |
+
from fastmcp.server.auth.verifiers import JWKData, JWKSData, JWTVerifier, RSAKeyPair
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
from fastmcp.utilities.tests import run_server_in_process
|
| 12 |
|
| 13 |
|
|
|
|
| 26 |
|
| 27 |
|
| 28 |
@pytest.fixture
|
| 29 |
+
def bearer_provider(rsa_key_pair: RSAKeyPair) -> JWTVerifier:
|
| 30 |
+
return JWTVerifier(
|
| 31 |
public_key=rsa_key_pair.public_key,
|
| 32 |
issuer="https://test.example.com",
|
| 33 |
audience="https://api.example.com",
|
|
|
|
| 42 |
run_kwargs: dict[str, Any] | None = None,
|
| 43 |
) -> None:
|
| 44 |
mcp = FastMCP(
|
| 45 |
+
auth=JWTVerifier(
|
| 46 |
public_key=public_key,
|
| 47 |
**auth_kwargs or {},
|
| 48 |
)
|
|
|
|
| 108 |
"""Tests for JWKS URI functionality."""
|
| 109 |
|
| 110 |
@pytest.fixture
|
| 111 |
+
def jwks_provider(self, rsa_key_pair: RSAKeyPair) -> JWTVerifier:
|
| 112 |
"""Provider configured with JWKS URI."""
|
| 113 |
+
return JWTVerifier(
|
| 114 |
jwks_uri="https://test.example.com/.well-known/jwks.json",
|
| 115 |
issuer="https://test.example.com",
|
| 116 |
audience="https://api.example.com",
|
|
|
|
| 132 |
async def test_jwks_token_validation(
|
| 133 |
self,
|
| 134 |
rsa_key_pair: RSAKeyPair,
|
| 135 |
+
jwks_provider: JWTVerifier,
|
| 136 |
mock_jwks_data: JWKSData,
|
| 137 |
httpx_mock: HTTPXMock,
|
| 138 |
):
|
|
|
|
| 154 |
async def test_jwks_token_validation_with_invalid_key(
|
| 155 |
self,
|
| 156 |
rsa_key_pair: RSAKeyPair,
|
| 157 |
+
jwks_provider: JWTVerifier,
|
| 158 |
mock_jwks_data: JWKSData,
|
| 159 |
httpx_mock: HTTPXMock,
|
| 160 |
):
|
|
|
|
| 174 |
async def test_jwks_token_validation_with_kid(
|
| 175 |
self,
|
| 176 |
rsa_key_pair: RSAKeyPair,
|
| 177 |
+
jwks_provider: JWTVerifier,
|
| 178 |
mock_jwks_data: JWKSData,
|
| 179 |
httpx_mock: HTTPXMock,
|
| 180 |
):
|
|
|
|
| 197 |
async def test_jwks_token_validation_with_kid_and_no_kid_in_token(
|
| 198 |
self,
|
| 199 |
rsa_key_pair: RSAKeyPair,
|
| 200 |
+
jwks_provider: JWTVerifier,
|
| 201 |
mock_jwks_data: JWKSData,
|
| 202 |
httpx_mock: HTTPXMock,
|
| 203 |
):
|
|
|
|
| 219 |
async def test_jwks_token_validation_with_no_kid_and_kid_in_jwks(
|
| 220 |
self,
|
| 221 |
rsa_key_pair: RSAKeyPair,
|
| 222 |
+
jwks_provider: JWTVerifier,
|
| 223 |
mock_jwks_data: JWKSData,
|
| 224 |
httpx_mock: HTTPXMock,
|
| 225 |
):
|
|
|
|
| 241 |
async def test_jwks_token_validation_with_kid_mismatch(
|
| 242 |
self,
|
| 243 |
rsa_key_pair: RSAKeyPair,
|
| 244 |
+
jwks_provider: JWTVerifier,
|
| 245 |
mock_jwks_data: JWKSData,
|
| 246 |
httpx_mock: HTTPXMock,
|
| 247 |
):
|
|
|
|
| 263 |
async def test_jwks_token_validation_with_multiple_keys_and_no_kid_in_token(
|
| 264 |
self,
|
| 265 |
rsa_key_pair: RSAKeyPair,
|
| 266 |
+
jwks_provider: JWTVerifier,
|
| 267 |
mock_jwks_data: JWKSData,
|
| 268 |
httpx_mock: HTTPXMock,
|
| 269 |
):
|
|
|
|
| 295 |
class TestBearerToken:
|
| 296 |
def test_initialization_with_public_key(self, rsa_key_pair: RSAKeyPair):
|
| 297 |
"""Test provider initialization with public key."""
|
| 298 |
+
provider = JWTVerifier(
|
| 299 |
public_key=rsa_key_pair.public_key, issuer="https://test.example.com"
|
| 300 |
)
|
| 301 |
|
|
|
|
| 305 |
|
| 306 |
def test_initialization_with_jwks_uri(self):
|
| 307 |
"""Test provider initialization with JWKS URI."""
|
| 308 |
+
provider = JWTVerifier(
|
| 309 |
jwks_uri="https://test.example.com/.well-known/jwks.json",
|
| 310 |
issuer="https://test.example.com",
|
| 311 |
)
|
|
|
|
| 319 |
with pytest.raises(
|
| 320 |
ValueError, match="Either public_key or jwks_uri must be provided"
|
| 321 |
):
|
| 322 |
+
JWTVerifier(issuer="https://test.example.com")
|
| 323 |
|
| 324 |
def test_initialization_rejects_both_key_and_uri(self, rsa_key_pair: RSAKeyPair):
|
| 325 |
"""Test that both public_key and jwks_uri cannot be provided."""
|
| 326 |
with pytest.raises(
|
| 327 |
ValueError, match="Provide either public_key or jwks_uri, not both"
|
| 328 |
):
|
| 329 |
+
JWTVerifier(
|
| 330 |
public_key=rsa_key_pair.public_key,
|
| 331 |
jwks_uri="https://test.example.com/.well-known/jwks.json",
|
| 332 |
issuer="https://test.example.com",
|
| 333 |
)
|
| 334 |
|
| 335 |
async def test_valid_token_validation(
|
| 336 |
+
self, rsa_key_pair: RSAKeyPair, bearer_provider: JWTVerifier
|
| 337 |
):
|
| 338 |
"""Test validation of a valid token."""
|
| 339 |
token = rsa_key_pair.create_token(
|
|
|
|
| 352 |
assert access_token.expires_at is not None
|
| 353 |
|
| 354 |
async def test_expired_token_rejection(
|
| 355 |
+
self, rsa_key_pair: RSAKeyPair, bearer_provider: JWTVerifier
|
| 356 |
):
|
| 357 |
"""Test rejection of expired tokens."""
|
| 358 |
token = rsa_key_pair.create_token(
|
|
|
|
| 366 |
assert access_token is None
|
| 367 |
|
| 368 |
async def test_invalid_issuer_rejection(
|
| 369 |
+
self, rsa_key_pair: RSAKeyPair, bearer_provider: JWTVerifier
|
| 370 |
):
|
| 371 |
"""Test rejection of tokens with invalid issuer."""
|
| 372 |
token = rsa_key_pair.create_token(
|
|
|
|
| 379 |
assert access_token is None
|
| 380 |
|
| 381 |
async def test_invalid_audience_rejection(
|
| 382 |
+
self, rsa_key_pair: RSAKeyPair, bearer_provider: JWTVerifier
|
| 383 |
):
|
| 384 |
"""Test rejection of tokens with invalid audience."""
|
| 385 |
token = rsa_key_pair.create_token(
|
|
|
|
| 393 |
|
| 394 |
async def test_no_issuer_validation_when_none(self, rsa_key_pair: RSAKeyPair):
|
| 395 |
"""Test that issuer validation is skipped when provider has no issuer configured."""
|
| 396 |
+
provider = JWTVerifier(
|
| 397 |
public_key=rsa_key_pair.public_key,
|
| 398 |
issuer=None, # No issuer validation
|
| 399 |
)
|
|
|
|
| 407 |
|
| 408 |
async def test_no_audience_validation_when_none(self, rsa_key_pair: RSAKeyPair):
|
| 409 |
"""Test that audience validation is skipped when provider has no audience configured."""
|
| 410 |
+
provider = JWTVerifier(
|
| 411 |
public_key=rsa_key_pair.public_key,
|
| 412 |
issuer="https://test.example.com",
|
| 413 |
audience=None, # No audience validation
|
|
|
|
| 424 |
|
| 425 |
async def test_multiple_audiences_validation(self, rsa_key_pair: RSAKeyPair):
|
| 426 |
"""Test validation with multiple audiences in token."""
|
| 427 |
+
provider = JWTVerifier(
|
| 428 |
public_key=rsa_key_pair.public_key,
|
| 429 |
issuer="https://test.example.com",
|
| 430 |
audience="https://api.example.com",
|
|
|
|
| 445 |
self, rsa_key_pair: RSAKeyPair
|
| 446 |
):
|
| 447 |
"""Test provider configured with multiple expected audiences."""
|
| 448 |
+
provider = JWTVerifier(
|
| 449 |
public_key=rsa_key_pair.public_key,
|
| 450 |
issuer="https://test.example.com",
|
| 451 |
audience=["https://api.example.com", "https://other-api.example.com"],
|
|
|
|
| 481 |
assert access_token3 is None
|
| 482 |
|
| 483 |
async def test_scope_extraction_string(
|
| 484 |
+
self, rsa_key_pair: RSAKeyPair, bearer_provider: JWTVerifier
|
| 485 |
):
|
| 486 |
"""Test scope extraction from space-separated string."""
|
| 487 |
token = rsa_key_pair.create_token(
|
|
|
|
| 497 |
assert set(access_token.scopes) == {"read", "write", "admin"}
|
| 498 |
|
| 499 |
async def test_scope_extraction_list(
|
| 500 |
+
self, rsa_key_pair: RSAKeyPair, bearer_provider: JWTVerifier
|
| 501 |
):
|
| 502 |
"""Test scope extraction from list format."""
|
| 503 |
token = rsa_key_pair.create_token(
|
|
|
|
| 513 |
assert set(access_token.scopes) == {"read", "write"}
|
| 514 |
|
| 515 |
async def test_no_scopes(
|
| 516 |
+
self, rsa_key_pair: RSAKeyPair, bearer_provider: JWTVerifier
|
| 517 |
):
|
| 518 |
"""Test token with no scopes."""
|
| 519 |
token = rsa_key_pair.create_token(
|
|
|
|
| 529 |
assert access_token.scopes == []
|
| 530 |
|
| 531 |
async def test_scp_claim_extraction_string(
|
| 532 |
+
self, rsa_key_pair: RSAKeyPair, bearer_provider: JWTVerifier
|
| 533 |
):
|
| 534 |
"""Test scope extraction from 'scp' claim with space-separated string."""
|
| 535 |
token = rsa_key_pair.create_token(
|
|
|
|
| 545 |
assert set(access_token.scopes) == {"read", "write", "admin"}
|
| 546 |
|
| 547 |
async def test_scp_claim_extraction_list(
|
| 548 |
+
self, rsa_key_pair: RSAKeyPair, bearer_provider: JWTVerifier
|
| 549 |
):
|
| 550 |
"""Test scope extraction from 'scp' claim with list format."""
|
| 551 |
token = rsa_key_pair.create_token(
|
|
|
|
| 563 |
assert set(access_token.scopes) == {"read", "write", "admin"}
|
| 564 |
|
| 565 |
async def test_scope_precedence_over_scp(
|
| 566 |
+
self, rsa_key_pair: RSAKeyPair, bearer_provider: JWTVerifier
|
| 567 |
):
|
| 568 |
"""Test that 'scope' claim takes precedence over 'scp' claim when both are present."""
|
| 569 |
token = rsa_key_pair.create_token(
|
|
|
|
| 581 |
assert access_token is not None
|
| 582 |
assert set(access_token.scopes) == {"read", "write"} # Only 'scope' claim used
|
| 583 |
|
| 584 |
+
async def test_malformed_token_rejection(self, bearer_provider: JWTVerifier):
|
| 585 |
"""Test rejection of malformed tokens."""
|
| 586 |
malformed_tokens = [
|
| 587 |
"not.a.jwt",
|
|
|
|
| 596 |
assert access_token is None
|
| 597 |
|
| 598 |
async def test_invalid_signature_rejection(
|
| 599 |
+
self, rsa_key_pair: RSAKeyPair, bearer_provider: JWTVerifier
|
| 600 |
):
|
| 601 |
"""Test rejection of tokens with invalid signatures."""
|
| 602 |
# Create a token with a different key pair
|
|
|
|
| 611 |
assert access_token is None
|
| 612 |
|
| 613 |
async def test_client_id_fallback(
|
| 614 |
+
self, rsa_key_pair: RSAKeyPair, bearer_provider: JWTVerifier
|
| 615 |
):
|
| 616 |
"""Test client_id extraction with fallback logic."""
|
| 617 |
# Test with explicit client_id claim
|
|
|
|
| 629 |
async def test_string_issuer_validation(self, rsa_key_pair: RSAKeyPair):
|
| 630 |
"""Test that string (non-URL) issuers are supported per RFC 7519."""
|
| 631 |
# Create provider with string issuer
|
| 632 |
+
provider = JWTVerifier(
|
| 633 |
public_key=rsa_key_pair.public_key,
|
| 634 |
issuer="my-service", # String issuer, not a URL
|
| 635 |
)
|
|
|
|
| 647 |
async def test_string_issuer_mismatch_rejection(self, rsa_key_pair: RSAKeyPair):
|
| 648 |
"""Test that mismatched string issuers are rejected."""
|
| 649 |
# Create provider with one string issuer
|
| 650 |
+
provider = JWTVerifier(
|
| 651 |
public_key=rsa_key_pair.public_key,
|
| 652 |
issuer="my-service",
|
| 653 |
)
|
|
|
|
| 664 |
async def test_url_issuer_still_works(self, rsa_key_pair: RSAKeyPair):
|
| 665 |
"""Test that URL issuers still work after the fix."""
|
| 666 |
# Create provider with URL issuer
|
| 667 |
+
provider = JWTVerifier(
|
| 668 |
public_key=rsa_key_pair.public_key,
|
| 669 |
issuer="https://my-auth-server.com", # URL issuer
|
| 670 |
)
|
|
|
|
| 683 |
class TestFastMCPBearerAuth:
|
| 684 |
def test_bearer_auth(self):
|
| 685 |
mcp = FastMCP(
|
| 686 |
+
auth=JWTVerifier(issuer="https://test.example.com", public_key="abc")
|
| 687 |
)
|
| 688 |
+
assert isinstance(mcp.auth, JWTVerifier)
|
| 689 |
|
| 690 |
async def test_unauthorized_access(self, mcp_server_url: str):
|
| 691 |
with pytest.raises(httpx.HTTPStatusError) as exc_info:
|
|
|
|
| 750 |
with pytest.raises(httpx.HTTPStatusError) as exc_info:
|
| 751 |
async with Client(mcp_server_url, auth=BearerAuth(token)) as client:
|
| 752 |
tools = await client.list_tools() # noqa: F841
|
| 753 |
+
# JWTVerifier returns 401 when verify_token returns None (invalid token)
|
| 754 |
+
# This is correct behavior - when TokenVerifier.verify_token returns None,
|
| 755 |
+
# it indicates the token is invalid (not just insufficient permissions)
|
| 756 |
+
assert exc_info.value.response.status_code == 401
|
| 757 |
assert "tools" not in locals()
|
| 758 |
|
| 759 |
async def test_token_with_sufficient_scopes(
|
tests/contrib/test_component_manager.py
CHANGED
|
@@ -4,7 +4,7 @@ from starlette.testclient import TestClient
|
|
| 4 |
|
| 5 |
from fastmcp import FastMCP
|
| 6 |
from fastmcp.contrib.component_manager import set_up_component_manager
|
| 7 |
-
from fastmcp.server.auth.
|
| 8 |
|
| 9 |
|
| 10 |
class TestComponentManagementRoutes:
|
|
@@ -340,7 +340,7 @@ class TestAuthComponentManagementRoutes:
|
|
| 340 |
"""Set up test fixtures."""
|
| 341 |
# Generate a key pair and create an auth provider
|
| 342 |
key_pair = RSAKeyPair.generate()
|
| 343 |
-
self.auth =
|
| 344 |
public_key=key_pair.public_key,
|
| 345 |
issuer="https://dev.example.com",
|
| 346 |
audience="my-dev-server",
|
|
@@ -425,7 +425,7 @@ class TestAuthComponentManagementRoutes:
|
|
| 425 |
assert tool.enabled is False
|
| 426 |
|
| 427 |
async def test_forbidden_enable_tool(self):
|
| 428 |
-
"""Test that
|
| 429 |
tool = await self.mcp._tool_manager.get_tool("test_tool")
|
| 430 |
tool.enabled = False
|
| 431 |
|
|
@@ -459,7 +459,7 @@ class TestAuthComponentManagementRoutes:
|
|
| 459 |
assert resource.enabled is True
|
| 460 |
|
| 461 |
async def test_forbidden_enable_resource(self):
|
| 462 |
-
"""Test that
|
| 463 |
resource = await self.mcp._resource_manager.get_resource("data://test_resource")
|
| 464 |
resource.enabled = False
|
| 465 |
|
|
@@ -515,7 +515,7 @@ class TestAuthComponentManagementRoutes:
|
|
| 515 |
assert prompt.enabled is True
|
| 516 |
|
| 517 |
async def test_forbidden_disable_prompt(self):
|
| 518 |
-
"""Test that
|
| 519 |
prompt = await self.mcp._prompt_manager.get_prompt("test_prompt")
|
| 520 |
prompt.enabled = True
|
| 521 |
|
|
@@ -606,11 +606,10 @@ class TestComponentManagerWithPathAuth:
|
|
| 606 |
def setup_method(self):
|
| 607 |
# Generate a key pair and create an auth provider
|
| 608 |
key_pair = RSAKeyPair.generate()
|
| 609 |
-
self.auth =
|
| 610 |
public_key=key_pair.public_key,
|
| 611 |
issuer="https://dev.example.com",
|
| 612 |
audience="my-dev-server",
|
| 613 |
-
required_scopes=["tool:write", "tool:read"],
|
| 614 |
)
|
| 615 |
self.mcp = FastMCP("TestServerWithPathAuth", auth=self.auth)
|
| 616 |
set_up_component_manager(
|
|
|
|
| 4 |
|
| 5 |
from fastmcp import FastMCP
|
| 6 |
from fastmcp.contrib.component_manager import set_up_component_manager
|
| 7 |
+
from fastmcp.server.auth.verifiers import JWTVerifier, RSAKeyPair
|
| 8 |
|
| 9 |
|
| 10 |
class TestComponentManagementRoutes:
|
|
|
|
| 340 |
"""Set up test fixtures."""
|
| 341 |
# Generate a key pair and create an auth provider
|
| 342 |
key_pair = RSAKeyPair.generate()
|
| 343 |
+
self.auth = JWTVerifier(
|
| 344 |
public_key=key_pair.public_key,
|
| 345 |
issuer="https://dev.example.com",
|
| 346 |
audience="my-dev-server",
|
|
|
|
| 425 |
assert tool.enabled is False
|
| 426 |
|
| 427 |
async def test_forbidden_enable_tool(self):
|
| 428 |
+
"""Test that requests with insufficient scopes are rejected."""
|
| 429 |
tool = await self.mcp._tool_manager.get_tool("test_tool")
|
| 430 |
tool.enabled = False
|
| 431 |
|
|
|
|
| 459 |
assert resource.enabled is True
|
| 460 |
|
| 461 |
async def test_forbidden_enable_resource(self):
|
| 462 |
+
"""Test that requests with insufficient scopes are rejected."""
|
| 463 |
resource = await self.mcp._resource_manager.get_resource("data://test_resource")
|
| 464 |
resource.enabled = False
|
| 465 |
|
|
|
|
| 515 |
assert prompt.enabled is True
|
| 516 |
|
| 517 |
async def test_forbidden_disable_prompt(self):
|
| 518 |
+
"""Test that requests with insufficient scopes are rejected."""
|
| 519 |
prompt = await self.mcp._prompt_manager.get_prompt("test_prompt")
|
| 520 |
prompt.enabled = True
|
| 521 |
|
|
|
|
| 606 |
def setup_method(self):
|
| 607 |
# Generate a key pair and create an auth provider
|
| 608 |
key_pair = RSAKeyPair.generate()
|
| 609 |
+
self.auth = JWTVerifier(
|
| 610 |
public_key=key_pair.public_key,
|
| 611 |
issuer="https://dev.example.com",
|
| 612 |
audience="my-dev-server",
|
|
|
|
| 613 |
)
|
| 614 |
self.mcp = FastMCP("TestServerWithPathAuth", auth=self.auth)
|
| 615 |
set_up_component_manager(
|
tests/deprecated/test_bearer_auth_provider.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
|
| 3 |
+
# reset deprecation warnings for this module
|
| 4 |
+
pytestmark = pytest.mark.filterwarnings("default::DeprecationWarning")
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def test_bearer_auth_provider_deprecated():
|
| 8 |
+
"""Test that BearerAuthProvider import shows deprecation warning."""
|
| 9 |
+
with pytest.warns(
|
| 10 |
+
DeprecationWarning,
|
| 11 |
+
match="The `fastmcp.server.auth.providers.bearer` module is deprecated and will be removed in a future version. Please use `fastmcp.server.auth.verifiers.JWTVerifier` instead of this module's BearerAuthProvider.",
|
| 12 |
+
):
|
| 13 |
+
from fastmcp.server.auth import BearerAuthProvider # noqa: F401
|
tests/server/auth/test_token_verifier_integration.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for TokenVerifier integration with FastMCP."""
|
| 2 |
+
|
| 3 |
+
import httpx
|
| 4 |
+
import pytest
|
| 5 |
+
from mcp.server.auth.provider import AccessToken
|
| 6 |
+
|
| 7 |
+
from fastmcp.server import FastMCP
|
| 8 |
+
from fastmcp.server.auth.verifiers import StaticTokenVerifier
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class TestTokenVerifierIntegration:
|
| 12 |
+
"""Test TokenVerifier integration with FastMCP server."""
|
| 13 |
+
|
| 14 |
+
def test_static_token_verifier_creation(self):
|
| 15 |
+
"""Test creating a FastMCP server with StaticTokenVerifier."""
|
| 16 |
+
verifier = StaticTokenVerifier(
|
| 17 |
+
{"test-token": {"client_id": "test-client", "scopes": ["read", "write"]}}
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
server = FastMCP("TestServer", auth=verifier)
|
| 21 |
+
assert server.auth is verifier
|
| 22 |
+
|
| 23 |
+
async def test_static_token_verifier_verify_token(self):
|
| 24 |
+
"""Test StaticTokenVerifier token verification."""
|
| 25 |
+
verifier = StaticTokenVerifier(
|
| 26 |
+
{
|
| 27 |
+
"valid-token": {
|
| 28 |
+
"client_id": "test-client",
|
| 29 |
+
"scopes": ["read", "write"],
|
| 30 |
+
"expires_at": None,
|
| 31 |
+
},
|
| 32 |
+
"scoped-token": {"client_id": "limited-client", "scopes": ["read"]},
|
| 33 |
+
}
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
# Test valid token
|
| 37 |
+
result = await verifier.verify_token("valid-token")
|
| 38 |
+
assert isinstance(result, AccessToken)
|
| 39 |
+
assert result.client_id == "test-client"
|
| 40 |
+
assert result.scopes == ["read", "write"]
|
| 41 |
+
assert result.token == "valid-token"
|
| 42 |
+
assert result.expires_at is None
|
| 43 |
+
|
| 44 |
+
# Test token with different scopes
|
| 45 |
+
result = await verifier.verify_token("scoped-token")
|
| 46 |
+
assert isinstance(result, AccessToken)
|
| 47 |
+
assert result.client_id == "limited-client"
|
| 48 |
+
assert result.scopes == ["read"]
|
| 49 |
+
|
| 50 |
+
# Test invalid token
|
| 51 |
+
result = await verifier.verify_token("invalid-token")
|
| 52 |
+
assert result is None
|
| 53 |
+
|
| 54 |
+
async def test_server_with_token_verifier_http_app(self):
|
| 55 |
+
"""Test that FastMCP server works with TokenVerifier for HTTP requests."""
|
| 56 |
+
verifier = StaticTokenVerifier(
|
| 57 |
+
{"test-token": {"client_id": "test-client", "scopes": ["read", "write"]}}
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
server = FastMCP("TestServer", auth=verifier)
|
| 61 |
+
|
| 62 |
+
@server.tool
|
| 63 |
+
def greet(name: str) -> str:
|
| 64 |
+
return f"Hello, {name}!"
|
| 65 |
+
|
| 66 |
+
# Create HTTP app
|
| 67 |
+
app = server.http_app(transport="http")
|
| 68 |
+
|
| 69 |
+
# Test unauthenticated request gets 401
|
| 70 |
+
async with httpx.AsyncClient(
|
| 71 |
+
transport=httpx.ASGITransport(app=app), base_url="http://test"
|
| 72 |
+
) as client:
|
| 73 |
+
response = await client.post("/mcp/")
|
| 74 |
+
assert response.status_code == 401
|
| 75 |
+
assert "WWW-Authenticate" in response.headers
|
| 76 |
+
|
| 77 |
+
def test_server_rejects_both_oauth_and_token_verifier(self):
|
| 78 |
+
"""Test that server raises error when both OAuth and TokenVerifier provided."""
|
| 79 |
+
from fastmcp.server.auth.providers.in_memory import InMemoryOAuthProvider
|
| 80 |
+
|
| 81 |
+
oauth_provider = InMemoryOAuthProvider("http://test.com")
|
| 82 |
+
token_verifier = StaticTokenVerifier({"token": {"client_id": "test"}})
|
| 83 |
+
|
| 84 |
+
# This should work - OAuth provider
|
| 85 |
+
server1 = FastMCP("Test1", auth=oauth_provider)
|
| 86 |
+
assert server1.auth is oauth_provider
|
| 87 |
+
|
| 88 |
+
# This should work - TokenVerifier
|
| 89 |
+
server2 = FastMCP("Test2", auth=token_verifier)
|
| 90 |
+
assert server2.auth is token_verifier
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
class TestJWTVerifierImport:
|
| 94 |
+
"""Test JWT token verifier can be imported and created."""
|
| 95 |
+
|
| 96 |
+
def test_jwt_verifier_requires_pyjwt(self):
|
| 97 |
+
"""Test that JWTVerifier raises helpful error without PyJWT."""
|
| 98 |
+
# Since PyJWT is likely installed in test environment, we'll just test construction
|
| 99 |
+
from fastmcp.server.auth.verifiers import JWTVerifier
|
| 100 |
+
|
| 101 |
+
# This should work if PyJWT is available
|
| 102 |
+
try:
|
| 103 |
+
verifier = JWTVerifier(public_key="dummy-key")
|
| 104 |
+
assert verifier.public_key == "dummy-key"
|
| 105 |
+
assert verifier.algorithm == "RS256"
|
| 106 |
+
except ImportError as e:
|
| 107 |
+
# If PyJWT not available, should get helpful error
|
| 108 |
+
assert "PyJWT is required" in str(e)
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
class TestIntrospectionTokenVerifierImport:
|
| 112 |
+
"""Test introspection token verifier can be imported and created."""
|
| 113 |
+
|
| 114 |
+
def test_introspection_verifier_creation(self):
|
| 115 |
+
"""Test IntrospectionTokenVerifier construction."""
|
| 116 |
+
from fastmcp.server.auth.verifiers import IntrospectionTokenVerifier
|
| 117 |
+
|
| 118 |
+
verifier = IntrospectionTokenVerifier(
|
| 119 |
+
"https://auth.example.com/introspect", "https://resource.example.com"
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
assert (
|
| 123 |
+
str(verifier.introspection_endpoint)
|
| 124 |
+
== "https://auth.example.com/introspect"
|
| 125 |
+
)
|
| 126 |
+
assert str(verifier.server_url) == "https://resource.example.com/"
|
| 127 |
+
assert verifier.validate_resource is False
|
| 128 |
+
assert verifier.required_scopes == []
|
| 129 |
+
|
| 130 |
+
def test_introspection_verifier_rejects_private_urls(self):
|
| 131 |
+
"""Test that IntrospectionTokenVerifier rejects private URLs."""
|
| 132 |
+
from fastmcp.server.auth.verifiers import IntrospectionTokenVerifier
|
| 133 |
+
|
| 134 |
+
with pytest.raises(ValueError, match="private/localhost URL"):
|
| 135 |
+
IntrospectionTokenVerifier(
|
| 136 |
+
"http://localhost/introspect", "https://resource.example.com"
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
with pytest.raises(ValueError, match="private/localhost URL"):
|
| 140 |
+
IntrospectionTokenVerifier(
|
| 141 |
+
"http://127.0.0.1/introspect", "https://resource.example.com"
|
| 142 |
+
)
|
tests/server/http/test_auth_setup.py
CHANGED
|
@@ -6,8 +6,8 @@ from mcp.server.auth.provider import AccessToken
|
|
| 6 |
from starlette.middleware import Middleware
|
| 7 |
from starlette.middleware.authentication import AuthenticationMiddleware
|
| 8 |
|
| 9 |
-
from fastmcp.server.auth.providers.bearer import BearerAuthProvider, RSAKeyPair
|
| 10 |
from fastmcp.server.auth.providers.in_memory import InMemoryOAuthProvider
|
|
|
|
| 11 |
from fastmcp.server.http import setup_auth_middleware_and_routes
|
| 12 |
|
| 13 |
|
|
@@ -15,10 +15,10 @@ class TestSetupAuthMiddlewareAndRoutes:
|
|
| 15 |
"""Test setup_auth_middleware_and_routes with TokenVerifier providers."""
|
| 16 |
|
| 17 |
@pytest.fixture
|
| 18 |
-
def
|
| 19 |
-
"""Create
|
| 20 |
key_pair = RSAKeyPair.generate()
|
| 21 |
-
return
|
| 22 |
public_key=key_pair.public_key,
|
| 23 |
issuer="https://test.example.com",
|
| 24 |
audience="https://api.example.com",
|
|
@@ -33,10 +33,10 @@ class TestSetupAuthMiddlewareAndRoutes:
|
|
| 33 |
required_scopes=["user"],
|
| 34 |
)
|
| 35 |
|
| 36 |
-
def
|
| 37 |
-
"""Test that setup works with
|
| 38 |
middleware, auth_routes, required_scopes = setup_auth_middleware_and_routes(
|
| 39 |
-
|
| 40 |
)
|
| 41 |
|
| 42 |
# Should return middleware list
|
|
@@ -51,11 +51,11 @@ class TestSetupAuthMiddlewareAndRoutes:
|
|
| 51 |
|
| 52 |
backend = auth_middleware.kwargs["backend"]
|
| 53 |
assert isinstance(backend, BearerAuthBackend)
|
| 54 |
-
assert backend.token_verifier is
|
| 55 |
|
| 56 |
# Should return auth routes
|
| 57 |
assert isinstance(auth_routes, list)
|
| 58 |
-
assert len(auth_routes)
|
| 59 |
|
| 60 |
# Should return required scopes
|
| 61 |
assert required_scopes == ["read", "write"]
|
|
@@ -81,25 +81,23 @@ class TestSetupAuthMiddlewareAndRoutes:
|
|
| 81 |
# Should return required scopes
|
| 82 |
assert required_scopes == ["user"]
|
| 83 |
|
| 84 |
-
def test_setup_preserves_provider_functionality(
|
| 85 |
-
self, bearer_provider: BearerAuthProvider
|
| 86 |
-
):
|
| 87 |
"""Test that setup doesn't break the provider's functionality."""
|
| 88 |
# Setup should not modify the provider
|
| 89 |
-
original_issuer =
|
| 90 |
-
original_scopes =
|
| 91 |
|
| 92 |
middleware, auth_routes, required_scopes = setup_auth_middleware_and_routes(
|
| 93 |
-
|
| 94 |
)
|
| 95 |
|
| 96 |
# Provider should be unchanged
|
| 97 |
-
assert
|
| 98 |
-
assert
|
| 99 |
|
| 100 |
# Provider should still work as TokenVerifier
|
| 101 |
-
assert hasattr(
|
| 102 |
-
assert callable(
|
| 103 |
|
| 104 |
|
| 105 |
class MockOAuthProvider:
|
|
|
|
| 6 |
from starlette.middleware import Middleware
|
| 7 |
from starlette.middleware.authentication import AuthenticationMiddleware
|
| 8 |
|
|
|
|
| 9 |
from fastmcp.server.auth.providers.in_memory import InMemoryOAuthProvider
|
| 10 |
+
from fastmcp.server.auth.verifiers import JWTVerifier, RSAKeyPair
|
| 11 |
from fastmcp.server.http import setup_auth_middleware_and_routes
|
| 12 |
|
| 13 |
|
|
|
|
| 15 |
"""Test setup_auth_middleware_and_routes with TokenVerifier providers."""
|
| 16 |
|
| 17 |
@pytest.fixture
|
| 18 |
+
def jwt_verifier(self) -> JWTVerifier:
|
| 19 |
+
"""Create JWTVerifier for testing."""
|
| 20 |
key_pair = RSAKeyPair.generate()
|
| 21 |
+
return JWTVerifier(
|
| 22 |
public_key=key_pair.public_key,
|
| 23 |
issuer="https://test.example.com",
|
| 24 |
audience="https://api.example.com",
|
|
|
|
| 33 |
required_scopes=["user"],
|
| 34 |
)
|
| 35 |
|
| 36 |
+
def test_setup_with_jwt_verifier(self, jwt_verifier: JWTVerifier):
|
| 37 |
+
"""Test that setup works with JWTVerifier as TokenVerifier."""
|
| 38 |
middleware, auth_routes, required_scopes = setup_auth_middleware_and_routes(
|
| 39 |
+
jwt_verifier
|
| 40 |
)
|
| 41 |
|
| 42 |
# Should return middleware list
|
|
|
|
| 51 |
|
| 52 |
backend = auth_middleware.kwargs["backend"]
|
| 53 |
assert isinstance(backend, BearerAuthBackend)
|
| 54 |
+
assert backend.token_verifier is jwt_verifier # type: ignore[attr-defined]
|
| 55 |
|
| 56 |
# Should return auth routes
|
| 57 |
assert isinstance(auth_routes, list)
|
| 58 |
+
assert len(auth_routes) == 0 # TokenVerifier should not have OAuth routes
|
| 59 |
|
| 60 |
# Should return required scopes
|
| 61 |
assert required_scopes == ["read", "write"]
|
|
|
|
| 81 |
# Should return required scopes
|
| 82 |
assert required_scopes == ["user"]
|
| 83 |
|
| 84 |
+
def test_setup_preserves_provider_functionality(self, jwt_verifier: JWTVerifier):
|
|
|
|
|
|
|
| 85 |
"""Test that setup doesn't break the provider's functionality."""
|
| 86 |
# Setup should not modify the provider
|
| 87 |
+
original_issuer = jwt_verifier.issuer
|
| 88 |
+
original_scopes = jwt_verifier.required_scopes
|
| 89 |
|
| 90 |
middleware, auth_routes, required_scopes = setup_auth_middleware_and_routes(
|
| 91 |
+
jwt_verifier
|
| 92 |
)
|
| 93 |
|
| 94 |
# Provider should be unchanged
|
| 95 |
+
assert jwt_verifier.issuer == original_issuer
|
| 96 |
+
assert jwt_verifier.required_scopes == original_scopes
|
| 97 |
|
| 98 |
# Provider should still work as TokenVerifier
|
| 99 |
+
assert hasattr(jwt_verifier, "verify_token")
|
| 100 |
+
assert callable(jwt_verifier.verify_token)
|
| 101 |
|
| 102 |
|
| 103 |
class MockOAuthProvider:
|
tests/server/http/test_bearer_auth_backend.py
CHANGED
|
@@ -5,7 +5,7 @@ from mcp.server.auth.middleware.bearer_auth import BearerAuthBackend
|
|
| 5 |
from mcp.server.auth.provider import AccessToken
|
| 6 |
from starlette.requests import HTTPConnection
|
| 7 |
|
| 8 |
-
from fastmcp.server.auth.
|
| 9 |
|
| 10 |
|
| 11 |
class TestBearerAuthBackendTokenVerifierIntegration:
|
|
@@ -17,9 +17,9 @@ class TestBearerAuthBackendTokenVerifierIntegration:
|
|
| 17 |
return RSAKeyPair.generate()
|
| 18 |
|
| 19 |
@pytest.fixture
|
| 20 |
-
def
|
| 21 |
-
"""Create
|
| 22 |
-
return
|
| 23 |
public_key=rsa_key_pair.public_key,
|
| 24 |
issuer="https://test.example.com",
|
| 25 |
audience="https://api.example.com",
|
|
@@ -36,18 +36,18 @@ class TestBearerAuthBackendTokenVerifierIntegration:
|
|
| 36 |
)
|
| 37 |
|
| 38 |
def test_bearer_auth_backend_constructor_accepts_token_verifier(
|
| 39 |
-
self,
|
| 40 |
):
|
| 41 |
"""Test that BearerAuthBackend constructor accepts TokenVerifier."""
|
| 42 |
# This should not raise an error
|
| 43 |
-
backend = BearerAuthBackend(
|
| 44 |
-
assert backend.token_verifier is
|
| 45 |
|
| 46 |
async def test_bearer_auth_backend_authenticate_with_valid_token(
|
| 47 |
-
self,
|
| 48 |
):
|
| 49 |
"""Test BearerAuthBackend authentication with valid token."""
|
| 50 |
-
backend = BearerAuthBackend(
|
| 51 |
|
| 52 |
# Create mock HTTPConnection with Authorization header
|
| 53 |
scope = {
|
|
@@ -66,10 +66,10 @@ class TestBearerAuthBackendTokenVerifierIntegration:
|
|
| 66 |
assert user.access_token.token == valid_token
|
| 67 |
|
| 68 |
async def test_bearer_auth_backend_authenticate_with_invalid_token(
|
| 69 |
-
self,
|
| 70 |
):
|
| 71 |
"""Test BearerAuthBackend authentication with invalid token."""
|
| 72 |
-
backend = BearerAuthBackend(
|
| 73 |
|
| 74 |
# Create mock HTTPConnection with invalid Authorization header
|
| 75 |
scope = {
|
|
@@ -82,10 +82,10 @@ class TestBearerAuthBackendTokenVerifierIntegration:
|
|
| 82 |
assert result is None
|
| 83 |
|
| 84 |
async def test_bearer_auth_backend_authenticate_with_no_header(
|
| 85 |
-
self,
|
| 86 |
):
|
| 87 |
"""Test BearerAuthBackend authentication with no Authorization header."""
|
| 88 |
-
backend = BearerAuthBackend(
|
| 89 |
|
| 90 |
# Create mock HTTPConnection without Authorization header
|
| 91 |
scope = {
|
|
@@ -98,10 +98,10 @@ class TestBearerAuthBackendTokenVerifierIntegration:
|
|
| 98 |
assert result is None
|
| 99 |
|
| 100 |
async def test_bearer_auth_backend_authenticate_with_non_bearer_token(
|
| 101 |
-
self,
|
| 102 |
):
|
| 103 |
"""Test BearerAuthBackend authentication with non-Bearer token."""
|
| 104 |
-
backend = BearerAuthBackend(
|
| 105 |
|
| 106 |
# Create mock HTTPConnection with Basic auth header
|
| 107 |
scope = {
|
|
|
|
| 5 |
from mcp.server.auth.provider import AccessToken
|
| 6 |
from starlette.requests import HTTPConnection
|
| 7 |
|
| 8 |
+
from fastmcp.server.auth.verifiers import JWTVerifier, RSAKeyPair
|
| 9 |
|
| 10 |
|
| 11 |
class TestBearerAuthBackendTokenVerifierIntegration:
|
|
|
|
| 17 |
return RSAKeyPair.generate()
|
| 18 |
|
| 19 |
@pytest.fixture
|
| 20 |
+
def jwt_verifier(self, rsa_key_pair: RSAKeyPair) -> JWTVerifier:
|
| 21 |
+
"""Create JWTVerifier for testing."""
|
| 22 |
+
return JWTVerifier(
|
| 23 |
public_key=rsa_key_pair.public_key,
|
| 24 |
issuer="https://test.example.com",
|
| 25 |
audience="https://api.example.com",
|
|
|
|
| 36 |
)
|
| 37 |
|
| 38 |
def test_bearer_auth_backend_constructor_accepts_token_verifier(
|
| 39 |
+
self, jwt_verifier: JWTVerifier
|
| 40 |
):
|
| 41 |
"""Test that BearerAuthBackend constructor accepts TokenVerifier."""
|
| 42 |
# This should not raise an error
|
| 43 |
+
backend = BearerAuthBackend(jwt_verifier)
|
| 44 |
+
assert backend.token_verifier is jwt_verifier # type: ignore[attr-defined]
|
| 45 |
|
| 46 |
async def test_bearer_auth_backend_authenticate_with_valid_token(
|
| 47 |
+
self, jwt_verifier: JWTVerifier, valid_token: str
|
| 48 |
):
|
| 49 |
"""Test BearerAuthBackend authentication with valid token."""
|
| 50 |
+
backend = BearerAuthBackend(jwt_verifier)
|
| 51 |
|
| 52 |
# Create mock HTTPConnection with Authorization header
|
| 53 |
scope = {
|
|
|
|
| 66 |
assert user.access_token.token == valid_token
|
| 67 |
|
| 68 |
async def test_bearer_auth_backend_authenticate_with_invalid_token(
|
| 69 |
+
self, jwt_verifier: JWTVerifier
|
| 70 |
):
|
| 71 |
"""Test BearerAuthBackend authentication with invalid token."""
|
| 72 |
+
backend = BearerAuthBackend(jwt_verifier)
|
| 73 |
|
| 74 |
# Create mock HTTPConnection with invalid Authorization header
|
| 75 |
scope = {
|
|
|
|
| 82 |
assert result is None
|
| 83 |
|
| 84 |
async def test_bearer_auth_backend_authenticate_with_no_header(
|
| 85 |
+
self, jwt_verifier: JWTVerifier
|
| 86 |
):
|
| 87 |
"""Test BearerAuthBackend authentication with no Authorization header."""
|
| 88 |
+
backend = BearerAuthBackend(jwt_verifier)
|
| 89 |
|
| 90 |
# Create mock HTTPConnection without Authorization header
|
| 91 |
scope = {
|
|
|
|
| 98 |
assert result is None
|
| 99 |
|
| 100 |
async def test_bearer_auth_backend_authenticate_with_non_bearer_token(
|
| 101 |
+
self, jwt_verifier: JWTVerifier
|
| 102 |
):
|
| 103 |
"""Test BearerAuthBackend authentication with non-Bearer token."""
|
| 104 |
+
backend = BearerAuthBackend(jwt_verifier)
|
| 105 |
|
| 106 |
# Create mock HTTPConnection with Basic auth header
|
| 107 |
scope = {
|
tests/server/http/test_http_auth_middleware.py
CHANGED
|
@@ -3,7 +3,7 @@ from mcp.server.auth.middleware.bearer_auth import RequireAuthMiddleware
|
|
| 3 |
from starlette.routing import Mount
|
| 4 |
|
| 5 |
from fastmcp.server import FastMCP
|
| 6 |
-
from fastmcp.server.auth.
|
| 7 |
from fastmcp.server.http import create_streamable_http_app
|
| 8 |
|
| 9 |
|
|
@@ -17,11 +17,11 @@ class TestStreamableHTTPAppResourceMetadataURL:
|
|
| 17 |
|
| 18 |
@pytest.fixture
|
| 19 |
def bearer_auth_provider(self, rsa_key_pair):
|
| 20 |
-
provider =
|
| 21 |
public_key=rsa_key_pair.public_key,
|
| 22 |
issuer="https://issuer",
|
| 23 |
audience="https://audience",
|
| 24 |
-
|
| 25 |
)
|
| 26 |
return provider
|
| 27 |
|
|
@@ -45,11 +45,11 @@ class TestStreamableHTTPAppResourceMetadataURL:
|
|
| 45 |
)
|
| 46 |
|
| 47 |
def test_trailing_slash_handling_in_resource_server_url(self, rsa_key_pair):
|
| 48 |
-
provider =
|
| 49 |
public_key=rsa_key_pair.public_key,
|
| 50 |
issuer="https://issuer",
|
| 51 |
audience="https://audience",
|
| 52 |
-
|
| 53 |
)
|
| 54 |
server = FastMCP(name="TestServer")
|
| 55 |
app = create_streamable_http_app(
|
|
|
|
| 3 |
from starlette.routing import Mount
|
| 4 |
|
| 5 |
from fastmcp.server import FastMCP
|
| 6 |
+
from fastmcp.server.auth.verifiers import JWTVerifier, RSAKeyPair
|
| 7 |
from fastmcp.server.http import create_streamable_http_app
|
| 8 |
|
| 9 |
|
|
|
|
| 17 |
|
| 18 |
@pytest.fixture
|
| 19 |
def bearer_auth_provider(self, rsa_key_pair):
|
| 20 |
+
provider = JWTVerifier(
|
| 21 |
public_key=rsa_key_pair.public_key,
|
| 22 |
issuer="https://issuer",
|
| 23 |
audience="https://audience",
|
| 24 |
+
resource_server_url="https://resource.example.com",
|
| 25 |
)
|
| 26 |
return provider
|
| 27 |
|
|
|
|
| 45 |
)
|
| 46 |
|
| 47 |
def test_trailing_slash_handling_in_resource_server_url(self, rsa_key_pair):
|
| 48 |
+
provider = JWTVerifier(
|
| 49 |
public_key=rsa_key_pair.public_key,
|
| 50 |
issuer="https://issuer",
|
| 51 |
audience="https://audience",
|
| 52 |
+
resource_server_url="https://resource.example.com/",
|
| 53 |
)
|
| 54 |
server = FastMCP(name="TestServer")
|
| 55 |
app = create_streamable_http_app(
|