fastmcp-auth 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ """FastMCP Auth - JWE authentication middleware for FastMCP/Starlette."""
2
+
3
+ from .middleware import AuthUser, JWKSAuthMiddleware, get_user
4
+ from .verifier import JWETokenVerifier
5
+
6
+ __version__ = "0.1.0"
7
+ __all__ = ["JWETokenVerifier", "JWKSAuthMiddleware", "get_user", "AuthUser"]
fastmcp_auth/cli.py ADDED
@@ -0,0 +1,172 @@
1
+ #!/usr/bin/env python3
2
+ """FastMCP Auth CLI - Key management for JWE authentication."""
3
+
4
+ import argparse
5
+ import base64
6
+ import json
7
+ import os
8
+ import secrets
9
+ import shutil
10
+ import subprocess
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ try:
15
+ from jwcrypto import jwk
16
+ except ImportError:
17
+ print("Error: 'jwcrypto' library is required.", file=sys.stderr)
18
+ print("Please run: pip install jwcrypto", file=sys.stderr)
19
+ sys.exit(1)
20
+
21
+ KEY_SIZE = 4096
22
+ KEY_FILES = ("mcp-private.json", "mcp-public.json")
23
+
24
+
25
+ def generate_keys(output_dir: Path) -> tuple[Path, Path]:
26
+ """Generate an RSA JWKS key pair and write to output_dir."""
27
+ output_dir.mkdir(parents=True, exist_ok=True)
28
+
29
+ key = jwk.JWK.generate(kty="RSA", size=KEY_SIZE)
30
+ key["kid"] = key.thumbprint()
31
+
32
+ private_jwks = {"keys": [json.loads(key.export_private())]}
33
+ public_jwks = {"keys": [json.loads(key.export_public())]}
34
+
35
+ private_key_path = output_dir / "mcp-private.json"
36
+ public_key_path = output_dir / "mcp-public.json"
37
+
38
+ with open(private_key_path, "w") as f:
39
+ json.dump(private_jwks, f, indent=2)
40
+
41
+ try:
42
+ os.chmod(private_key_path, 0o600)
43
+ except OSError:
44
+ pass
45
+
46
+ with open(public_key_path, "w") as f:
47
+ json.dump(public_jwks, f, indent=2)
48
+
49
+ return private_key_path, public_key_path
50
+
51
+
52
+ def secure_delete(file_path: Path) -> None:
53
+ """Securely delete a file using shred or overwrite fallback."""
54
+ if not file_path.exists():
55
+ return
56
+
57
+ if shutil.which("shred"):
58
+ subprocess.run(
59
+ ["shred", "--remove", "--zero", "--iterations=3", str(file_path)],
60
+ check=True,
61
+ )
62
+ return
63
+
64
+ try:
65
+ size = file_path.stat().st_size
66
+ with open(file_path, "r+b") as f:
67
+ f.write(secrets.token_bytes(size))
68
+ f.flush()
69
+ os.fsync(f.fileno())
70
+ except OSError:
71
+ pass
72
+
73
+ file_path.unlink()
74
+
75
+
76
+ def clean_keys(key_dir: Path) -> list[str]:
77
+ """Remove key files from key_dir. Returns list of removed paths."""
78
+ removed = []
79
+ for name in KEY_FILES:
80
+ key_file = key_dir / name
81
+ if not key_file.exists():
82
+ continue
83
+
84
+ if "private" in name:
85
+ secure_delete(key_file)
86
+ else:
87
+ key_file.unlink()
88
+
89
+ removed.append(str(key_file))
90
+ return removed
91
+
92
+
93
+ def cmd_generate(args: argparse.Namespace) -> None:
94
+ output_dir = args.output.resolve()
95
+ private_key, public_key = generate_keys(output_dir)
96
+
97
+ print(f"Keys generated (JWKS format):")
98
+ print(f" Private: {private_key}")
99
+ print(f" Public: {public_key}")
100
+ print(f"\nFor local development, add to .env:")
101
+ print(f" MCP_KEY_FILE_PATH={private_key}")
102
+ print(f"\nAdd to .gitignore:")
103
+ print(f" .keys/")
104
+
105
+
106
+ def cmd_k8s(args: argparse.Namespace) -> None:
107
+ output_dir = args.output.resolve()
108
+ private_key, _ = generate_keys(output_dir)
109
+
110
+ with open(private_key, "rb") as f:
111
+ b64_key = base64.b64encode(f.read()).decode("utf-8")
112
+
113
+ k8s_yaml = f"""\
114
+ apiVersion: v1
115
+ kind: Secret
116
+ metadata:
117
+ name: {args.secret_name}
118
+ namespace: {args.namespace}
119
+ type: Opaque
120
+ data:
121
+ mcp_jwks: {b64_key}
122
+ """
123
+ print(k8s_yaml)
124
+ print(f"# Apply: fastmcp-auth k8s | kubectl apply -f -", file=sys.stderr)
125
+ print(f"# Then clean local keys: fastmcp-auth clean", file=sys.stderr)
126
+
127
+
128
+ def cmd_clean(args: argparse.Namespace) -> None:
129
+ removed = clean_keys(args.output.resolve())
130
+ if removed:
131
+ print("Removed:\n " + "\n ".join(removed))
132
+ else:
133
+ print(f"No keys found in {args.output}")
134
+
135
+
136
+ def main() -> None:
137
+ parser = argparse.ArgumentParser(
138
+ prog="fastmcp-auth", description="FastMCP Auth - Key Management (JWKS)"
139
+ )
140
+ subs = parser.add_subparsers(dest="command", required=True)
141
+
142
+ commands = [
143
+ ("generate", "Generate RSA JWKS key pair", cmd_generate),
144
+ ("k8s", "Generate keys and output Kubernetes secret YAML", cmd_k8s),
145
+ ("clean", "Remove keys securely", cmd_clean),
146
+ ]
147
+
148
+ for name, help_text, handler in commands:
149
+ p = subs.add_parser(name, help=help_text)
150
+ p.add_argument(
151
+ "-o",
152
+ "--output",
153
+ type=Path,
154
+ default=Path(".keys"),
155
+ help="Key directory (default: .keys)",
156
+ )
157
+ p.set_defaults(func=handler)
158
+
159
+ k8s = subs.choices["k8s"]
160
+ k8s.add_argument(
161
+ "-n", "--namespace", default="default", help="Kubernetes namespace"
162
+ )
163
+ k8s.add_argument(
164
+ "-s", "--secret-name", default="mcp-server-keys", help="Secret name"
165
+ )
166
+
167
+ args = parser.parse_args()
168
+ args.func(args)
169
+
170
+
171
+ if __name__ == "__main__":
172
+ main()
@@ -0,0 +1,61 @@
1
+ """JWE authentication middleware for Starlette/FastMCP."""
2
+
3
+ from contextvars import ContextVar
4
+ from typing import Any
5
+
6
+ from starlette.middleware.base import BaseHTTPMiddleware
7
+ from starlette.requests import Request
8
+ from starlette.responses import JSONResponse, Response
9
+
10
+ from .verifier import JWETokenVerifier
11
+
12
+ _user_context: ContextVar[dict] = ContextVar("user", default={})
13
+
14
+
15
+ class AuthUser(dict):
16
+ """User claims with attribute access. Missing keys return None."""
17
+
18
+ def __getattr__(self, name: str) -> Any:
19
+ return self.get(name)
20
+
21
+
22
+ def get_user() -> AuthUser:
23
+ """Get authenticated user from current request context."""
24
+ return AuthUser(_user_context.get())
25
+
26
+
27
+ class JWKSAuthMiddleware(BaseHTTPMiddleware):
28
+ """
29
+ Middleware that decrypts JWE Bearer tokens and serves JWKS endpoint.
30
+
31
+ Usage:
32
+ app = mcp.http_app()
33
+ app.add_middleware(JWKSAuthMiddleware)
34
+ uvicorn.run(app, host="0.0.0.0", port=8000)
35
+ """
36
+
37
+ def __init__(
38
+ self,
39
+ app,
40
+ verifier: JWETokenVerifier | None = None,
41
+ jwks_path: str = "/.well-known/jwks.json",
42
+ ):
43
+ super().__init__(app)
44
+ self.verifier = verifier or JWETokenVerifier()
45
+ self.jwks_path = jwks_path
46
+
47
+ async def dispatch(self, request: Request, call_next) -> Response:
48
+ if request.url.path == self.jwks_path:
49
+ return JSONResponse(self.verifier.get_jwks())
50
+
51
+ claims = {}
52
+ if (auth := request.headers.get("authorization", "")).lower().startswith(
53
+ "bearer "
54
+ ):
55
+ claims = await self.verifier.verify_token(auth[7:]) or {}
56
+
57
+ token = _user_context.set(claims)
58
+ try:
59
+ return await call_next(request)
60
+ finally:
61
+ _user_context.reset(token)
@@ -0,0 +1,52 @@
1
+ """JWE token verification and JWKS support."""
2
+
3
+ import json
4
+ import logging
5
+ import os
6
+ from typing import Any
7
+
8
+ from jose import jwe
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class JWETokenVerifier:
14
+ """Verifies JWE tokens and exposes public key as JWKS."""
15
+
16
+ def __init__(self) -> None:
17
+ self._jwk = self._load_key_from_env()
18
+
19
+ @staticmethod
20
+ def _load_key_from_env() -> dict | None:
21
+ path = os.environ.get("MCP_KEY_FILE_PATH")
22
+ if not path:
23
+ return None
24
+
25
+ try:
26
+ with open(path) as f:
27
+ data = json.load(f)
28
+
29
+ if "keys" in data and isinstance(data["keys"], list):
30
+ return data["keys"][0]
31
+ return data
32
+
33
+ except (FileNotFoundError, json.JSONDecodeError) as e:
34
+ logger.error(f"Key loading error: {e}")
35
+ return None
36
+
37
+ async def verify_token(self, token: str) -> dict[str, Any] | None:
38
+ if not self._jwk:
39
+ return None
40
+ try:
41
+ payload = json.loads(jwe.decrypt(token, self._jwk))
42
+ return payload.get("data", payload)
43
+ except Exception as e:
44
+ logger.debug(f"Token verification failed: {e}")
45
+ return None
46
+
47
+ def get_jwks(self) -> dict[str, Any]:
48
+ if not self._jwk:
49
+ return {"keys": []}
50
+ public_fields = {"kty", "kid", "alg", "use", "n", "e"}
51
+ public_jwk = {k: v for k, v in self._jwk.items() if k in public_fields}
52
+ return {"keys": [public_jwk]}
@@ -0,0 +1,280 @@
1
+ Metadata-Version: 2.4
2
+ Name: fastmcp-auth
3
+ Version: 0.1.0
4
+ Summary: JWE authentication middleware for FastMCP/Starlette applications
5
+ Author: Aurel Bartmann
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/fhswf/fastmcp-auth
8
+ Project-URL: Documentation, https://github.com/fhswf/fastmcp-auth#readme
9
+ Project-URL: Repository, https://github.com/fhswf/fastmcp-auth
10
+ Project-URL: Issues, https://github.com/fhswf/fastmcp-auth/issues
11
+ Keywords: mcp,fastmcp,jwe,jwks,authentication,middleware,starlette
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Framework :: FastAPI
17
+ Classifier: Topic :: Security
18
+ Classifier: Topic :: Security :: Cryptography
19
+ Requires-Python: >=3.13
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: python-jose[cryptography]>=3.5.0
23
+ Requires-Dist: starlette>=0.52.1
24
+ Requires-Dist: jwcrypto>=1.5.6
25
+ Dynamic: license-file
26
+
27
+ # fastmcp-auth
28
+
29
+ JWE authentication middleware for [FastMCP](https://github.com/jlowin/fastmcp) / Starlette servers. Encrypt user data end-to-end so that only your MCP server can read it.
30
+
31
+ ## What it does
32
+
33
+ `fastmcp-auth` gives your FastMCP server two things:
34
+
35
+ 1. **A middleware** that intercepts incoming requests, decrypts a JWE Bearer token, and makes the authenticated user's claims available via `get_user()`.
36
+ 2. **A CLI** (`fastmcp-auth`) that generates RSA key pairs in JWKS format, outputs Kubernetes Secret YAML, and securely deletes local keys when you're done.
37
+
38
+ ---
39
+
40
+ ## Installation
41
+
42
+ ```bash
43
+ pip install fastmcp-auth
44
+ ```
45
+
46
+ This installs the library **and** the `fastmcp-auth` CLI.
47
+
48
+ ---
49
+
50
+ ## Quick start (local development)
51
+
52
+ ### 1. Generate keys
53
+
54
+ ```bash
55
+ fastmcp-auth generate
56
+ ```
57
+
58
+ Output:
59
+
60
+ ```
61
+ Keys generated (JWKS format):
62
+ Private: .keys/mcp-private.json
63
+ Public: .keys/mcp-public.json
64
+ ```
65
+
66
+ Add `.keys/` to your `.gitignore` immediately.
67
+
68
+ ### 2. Set the environment variable
69
+
70
+ Create a `.env` file (or export directly):
71
+
72
+ ```bash
73
+ MCP_KEY_FILE_PATH=.keys/mcp-private.json
74
+ ```
75
+
76
+ ### 3. Add the middleware to your FastMCP server
77
+
78
+ ```python
79
+ from fastmcp import FastMCP
80
+ from fastmcp_auth import JWKSAuthMiddleware, get_user
81
+ import uvicorn
82
+
83
+ mcp = FastMCP("My Server")
84
+
85
+ # Register your tools on `mcp` as usual …
86
+
87
+ app = mcp.http_app()
88
+ app.add_middleware(JWKSAuthMiddleware)
89
+
90
+ if __name__ == "__main__":
91
+ uvicorn.run(app, host="0.0.0.0", port=8000)
92
+ ```
93
+
94
+ ### 4. Access user claims inside any tool
95
+
96
+ ```python
97
+ @mcp.tool()
98
+ def whoami() -> str:
99
+ user = get_user()
100
+ return f"Hello, {user.name or 'anonymous'}!"
101
+ ```
102
+
103
+ `get_user()` returns an `AuthUser` dict. Access claims as attributes — missing keys return `None` instead of raising.
104
+
105
+ ---
106
+
107
+ ## Kubernetes deployment
108
+
109
+ ### 1. Generate keys and pipe straight into kubectl
110
+
111
+ ```bash
112
+ fastmcp-auth k8s | kubectl apply -f -
113
+ ```
114
+
115
+ This creates a Kubernetes `Secret` named `mcp-server-keys` in the `default` namespace. Customise with flags:
116
+
117
+ ```bash
118
+ fastmcp-auth k8s --namespace my-ns --secret-name my-mcp-keys | kubectl apply -f -
119
+ ```
120
+
121
+ ### 2. Clean up local key material
122
+
123
+ ```bash
124
+ fastmcp-auth clean
125
+ ```
126
+
127
+ Uses `shred` (Linux) for secure deletion when available; falls back to overwrite-then-delete.
128
+
129
+ ### 3. Mount the secret in your deployment
130
+
131
+ Add the following snippets to your existing Deployment YAML.
132
+
133
+ **Volume definition** (under `spec.template.spec.volumes`):
134
+
135
+ ```yaml
136
+ - name: mcp-secret-volume
137
+ secret:
138
+ secretName: mcp-server-keys # must match --secret-name
139
+ defaultMode: 0400
140
+ items:
141
+ - key: mcp_jwks
142
+ path: key.json
143
+ ```
144
+
145
+ **Volume mount** (under your container's `volumeMounts`):
146
+
147
+ ```yaml
148
+ - mountPath: /etc/mcp/secrets
149
+ name: mcp-secret-volume
150
+ readOnly: true
151
+ ```
152
+
153
+ **Environment variable** (under `env`):
154
+
155
+ ```yaml
156
+ - name: MCP_KEY_FILE_PATH
157
+ value: "/etc/mcp/secrets/key.json"
158
+ ```
159
+
160
+ A full example Deployment + Service is in [`examples/k8s-deployment.yaml`](examples/k8s-deployment.yaml).
161
+
162
+ ---
163
+
164
+ ## CLI reference
165
+
166
+ All commands accept `-o / --output <dir>` to change the key directory (default: `.keys`).
167
+
168
+ | Command | Description |
169
+ |---|---|
170
+ | `fastmcp-auth generate` | Generate an RSA-4096 JWKS key pair |
171
+ | `fastmcp-auth k8s` | Generate keys and print a Kubernetes Secret YAML to stdout |
172
+ | `fastmcp-auth clean` | Securely delete keys from the output directory |
173
+
174
+ ### `fastmcp-auth k8s` flags
175
+
176
+ | Flag | Default | Description |
177
+ |---|---|---|
178
+ | `-n, --namespace` | `default` | Kubernetes namespace |
179
+ | `-s, --secret-name` | `mcp-server-keys` | Name of the K8s Secret |
180
+
181
+ ---
182
+
183
+ ## API reference
184
+
185
+ ### `JWKSAuthMiddleware`
186
+
187
+ Starlette middleware. Attach it to your FastMCP HTTP app:
188
+
189
+ ```python
190
+ app.add_middleware(
191
+ JWKSAuthMiddleware,
192
+ verifier=None, # optional: provide your own JWETokenVerifier
193
+ jwks_path="/.well-known/jwks.json", # public-key endpoint path
194
+ )
195
+ ```
196
+
197
+ The middleware automatically serves your server's **public** JWKS at the configured path so that token producers can discover your encryption key.
198
+
199
+ ### `get_user() -> AuthUser`
200
+
201
+ Returns the authenticated user's claims for the current request. Safe to call from any async context (tools, routes, dependencies) during request handling.
202
+
203
+ ```python
204
+ user = get_user()
205
+ user.email # claim value or None
206
+ user["role"] # also works as a regular dict
207
+ ```
208
+
209
+ ### `JWETokenVerifier`
210
+
211
+ Lower-level class if you need to verify tokens outside the middleware:
212
+
213
+ ```python
214
+ from fastmcp_auth import JWETokenVerifier
215
+
216
+ verifier = JWETokenVerifier() # reads MCP_KEY_FILE_PATH
217
+ claims = await verifier.verify_token(token_string)
218
+ public_jwks = verifier.get_jwks() # for publishing
219
+ ```
220
+
221
+ ---
222
+
223
+ ## Environment variables
224
+
225
+ | Variable | Required | Description |
226
+ |---|---|---|
227
+ | `MCP_KEY_FILE_PATH` | Yes | Path to the private JWKS JSON file |
228
+
229
+ ---
230
+
231
+ ## How the token flow works
232
+
233
+ ```
234
+ Producer (client/gateway) MCP Server
235
+ ――――――――――――――――――――――――― ――――――――――
236
+ GET /.well-known/jwks.json
237
+ ◄──── { public RSA key }
238
+ Encrypt claims with public key
239
+ POST /mcp/tool ──────────────────►
240
+ Authorization: Bearer <JWE>
241
+ Middleware decrypts JWE
242
+ with private key
243
+ ─► get_user() returns claims
244
+ ```
245
+
246
+ 1. The token producer fetches the server's public key from `/.well-known/jwks.json`.
247
+ 2. It encrypts a JSON payload (user claims) into a JWE token using that public key.
248
+ 3. The token is sent as a `Bearer` token in the `Authorization` header.
249
+ 4. The middleware decrypts it with the private key and exposes claims via `get_user()`.
250
+
251
+ ---
252
+
253
+ ## Project structure
254
+
255
+ ```
256
+ fastmcp-auth/
257
+ ├── pyproject.toml
258
+ ├── README.md
259
+ ├── LICENSE
260
+ ├── CHANGELOG.md
261
+ ├── requirements.txt
262
+ ├── requirements-dev.txt
263
+ ├── MANIFEST.in
264
+ ├── .gitignore
265
+ ├── fastmcp_auth/
266
+ │ ├── __init__.py
267
+ │ ├── py.typed
268
+ │ ├── cli.py
269
+ │ ├── middleware.py
270
+ │ └── verifier.py
271
+ ├── tests/
272
+ └── examples/
273
+ ├── server.py
274
+ └── k8s-deployment.yaml
275
+ ```
276
+
277
+ ---
278
+ ## License
279
+
280
+ MIT
@@ -0,0 +1,10 @@
1
+ fastmcp_auth/__init__.py,sha256=blsHk0reNnRt9LNy4gmMSTh5gtONDxJTiLUpeAmMJCA,277
2
+ fastmcp_auth/cli.py,sha256=3yAzLge3XyM5ScCas_2sJ31tnFI1HtsChohj9XM8IiA,4624
3
+ fastmcp_auth/middleware.py,sha256=BSIL49eid-TQwt-G9vMYBd8DYxOziRGRRCZzSBo2Sik,1771
4
+ fastmcp_auth/verifier.py,sha256=jj-30ivr_KnHitp8gKVy_XtIONTlcDc16MsXEC2xMLE,1493
5
+ fastmcp_auth-0.1.0.dist-info/licenses/LICENSE,sha256=BD0bvGFuIMtkoA_GGht8ks9SrJke-X2JiWfowiG0VNA,1084
6
+ fastmcp_auth-0.1.0.dist-info/METADATA,sha256=YzbpXxIdb753VuQhUeToBwZAIeFN8ACuDMjOAxHV9JI,7303
7
+ fastmcp_auth-0.1.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
8
+ fastmcp_auth-0.1.0.dist-info/entry_points.txt,sha256=2ay8xbtcCEWgeZJYOPIhIbQbwg4pqFUIRJqckI5N84s,55
9
+ fastmcp_auth-0.1.0.dist-info/top_level.txt,sha256=Eox9oXs3M9sJMiZn9uDnelrMvZG01QyXKN5Uf5C-8fA,13
10
+ fastmcp_auth-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.10.2)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ fastmcp-auth = fastmcp_auth.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Fachhochschule Südwestfalen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ fastmcp_auth