mcp-authkit 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,297 @@
1
+ Metadata-Version: 2.4
2
+ Name: mcp-authkit
3
+ Version: 0.1.0
4
+ Summary: Pluggable OAuth 2.0 + credentials elicitation library for FastMCP servers
5
+ License: MIT
6
+ Project-URL: Homepage, https://github.com/masterela/mcp-authkit
7
+ Project-URL: Documentation, https://masterela.github.io/mcp-authkit/
8
+ Project-URL: Repository, https://github.com/masterela/mcp-authkit
9
+ Project-URL: Changelog, https://github.com/masterela/mcp-authkit/blob/main/CHANGELOG.md
10
+ Requires-Python: >=3.11
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Requires-Dist: fastapi>=0.115
14
+ Requires-Dist: httpx>=0.27
15
+ Requires-Dist: python-jose[cryptography]>=3.3
16
+ Requires-Dist: mcp>=1.6
17
+ Requires-Dist: jinja2>=3.1
18
+ Requires-Dist: cryptography>=42
19
+ Provides-Extra: redis
20
+ Requires-Dist: redis>=5; extra == "redis"
21
+ Provides-Extra: experimental
22
+ Requires-Dist: uvicorn[standard]>=0.30; extra == "experimental"
23
+ Requires-Dist: pydantic-settings>=2.6; extra == "experimental"
24
+ Requires-Dist: python-dotenv>=1.0; extra == "experimental"
25
+ Dynamic: license-file
26
+
27
+ # mcp-authkit
28
+
29
+ [![CI](https://github.com/masterela/mcp-authkit/actions/workflows/ci.yml/badge.svg)](https://github.com/masterela/mcp-authkit/actions/workflows/ci.yml)
30
+ [![Coverage](https://codecov.io/gh/masterela/mcp-authkit/branch/main/graph/badge.svg)](https://codecov.io/gh/masterela/mcp-authkit)
31
+ [![Docs](https://img.shields.io/badge/docs-GitHub%20Pages-blue)](https://masterela.github.io/mcp-authkit/)
32
+ [![PyPI version](https://img.shields.io/pypi/v/mcp-authkit)](https://pypi.org/project/mcp-authkit/)
33
+ [![Python](https://img.shields.io/pypi/pyversions/mcp-authkit)](https://pypi.org/project/mcp-authkit/)
34
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
35
+
36
+ Pluggable authentication library for [FastMCP](https://github.com/modelcontextprotocol/python-sdk) servers built on FastAPI / Starlette.
37
+
38
+ Supports two independent auth legs:
39
+
40
+ 1. **Primary leg** — every MCP session is gated behind a standard OIDC provider (Keycloak, Okta, Entra ID, Duende, Auth0, …) using JWT bearer tokens. The MCP client (e.g. VS Code Copilot) handles the PKCE flow automatically.
41
+ 2. **Secondary leg** — individual MCP tools can additionally require a third-party OAuth token or a PAT / API key, collected on demand via MCP elicitation.
42
+
43
+ ---
44
+
45
+ ## Installation
46
+
47
+ ```bash
48
+ pip install mcp-authkit
49
+
50
+ # Optional Redis storage backend
51
+ pip install "mcp-authkit[redis]"
52
+ ```
53
+
54
+ ---
55
+
56
+ ## Package layout
57
+
58
+ ```
59
+ mcpauthkit/
60
+ ├── __init__.py # Public exports: OAuthProvider, CredentialsProvider, …
61
+ ├── auth_middleware.py # JwtAuthMiddleware (BaseHTTPMiddleware)
62
+ ├── auth_routes.py # oauth_meta_router() — well-known + DCR façade
63
+ ├── jwt_validator.py # OIDC JWKS-based JWT validation (provider-agnostic)
64
+ ├── providers/
65
+ │ ├── oauth_provider.py # OAuthProvider — third-party OAuth 2.0 leg
66
+ │ ├── credentials_provider.py # CredentialsProvider — PAT / API-key form
67
+ │ └── templates/ # Jinja2 HTML templates (no external CDN)
68
+ └── store/
69
+ ├── base.py # Abstract store interfaces
70
+ ├── memory.py # In-process store (dev / testing)
71
+ ├── file_store.py # Fernet-encrypted file store
72
+ ├── redis_store.py # Async Redis store (requires redis extra)
73
+ ├── encryption.py # Fernet key derivation helpers
74
+ └── factory.py # create_stores() — env-driven backend selection
75
+ ```
76
+
77
+ The repository also contains `server.py` (a complete example server using GitHub OAuth and Confluence credentials) and a `docker-compose.yml` / `keycloak-realm.json` for running a local Keycloak instance. See the [quickstart repo](https://github.com/masterela/mcp-authkit-quickstart) for a guided walkthrough.
78
+
79
+ ---
80
+
81
+ ## Primary auth leg — OIDC JWT validation
82
+
83
+ Every request to the MCP server must carry a valid `Authorization: Bearer <token>` issued by the configured OIDC provider. The middleware performs JWKS discovery automatically and caches keys for 10 minutes.
84
+
85
+ ```python
86
+ from mcpauthkit.auth_middleware import JwtAuthMiddleware
87
+ from mcpauthkit.auth_routes import oauth_meta_router
88
+
89
+ ISSUER_URL = "https://sso.example.com/realms/my-realm"
90
+ SERVER_URL = "https://my-mcp-server.example.com"
91
+ CLIENT_ID = "my-mcp-public-client" # pre-registered public client
92
+
93
+ # Publish RFC 8414 / MCP-spec well-known endpoints + DCR façade
94
+ app.include_router(oauth_meta_router(
95
+ server_base_url=SERVER_URL,
96
+ issuer_url=ISSUER_URL,
97
+ client_id=CLIENT_ID,
98
+ ))
99
+
100
+ # Validate JWT on every request; populate current_user ContextVar
101
+ app.add_middleware(
102
+ JwtAuthMiddleware,
103
+ issuer_url=ISSUER_URL,
104
+ current_user=current_user,
105
+ server_base_url=SERVER_URL,
106
+ open_paths=(
107
+ "/.well-known", "/health", "/register",
108
+ github_oauth.callback_path,
109
+ *confluence_creds.open_paths,
110
+ ),
111
+ )
112
+ ```
113
+
114
+ ### `oauth_meta_router`
115
+
116
+ Returns a FastAPI `APIRouter` with:
117
+
118
+ | Route | Purpose |
119
+ |---|---|
120
+ | `GET /.well-known/oauth-protected-resource` | RFC 9728 resource metadata |
121
+ | `GET /.well-known/oauth-protected-resource/{path}` | Wildcard variant (some clients append the resource path) |
122
+ | `GET /.well-known/oauth-authorization-server` | RFC 8414 authorization server metadata (proxied from the real OIDC provider) |
123
+ | `POST /register` | Dynamic Client Registration façade — always returns the pre-registered public client ID |
124
+
125
+ ### `JwtAuthMiddleware`
126
+
127
+ `BaseHTTPMiddleware` subclass. Parameters passed via `app.add_middleware(...)`:
128
+
129
+ | Parameter | Type | Description |
130
+ |---|---|---|
131
+ | `issuer_url` | `str` | OIDC issuer base URL (e.g. Keycloak realm URL) |
132
+ | `current_user` | `ContextVar` | Populated with verified claims on each authenticated request |
133
+ | `server_base_url` | `str` | Used in `WWW-Authenticate` realm / resource-metadata URIs |
134
+ | `open_paths` | `tuple[str, ...]` | Path prefixes that bypass authentication |
135
+
136
+ Returns `401` with a standards-compliant `WWW-Authenticate: Bearer …` header when authentication fails, triggering the PKCE flow in the MCP client automatically.
137
+
138
+ ### `jwt_validator`
139
+
140
+ Provider-agnostic OIDC JWT validation. Supports RS256/384/512, PS256/384/512, ES256/384/512, EdDSA. Discovers `jwks_uri` automatically via `{issuer_url}/.well-known/openid-configuration`. OIDC config and JWKS are cached for 10 minutes.
141
+
142
+ ---
143
+
144
+ ## Secondary auth leg — tool-level credential acquisition
145
+
146
+ Individual tools can be gated behind additional credentials collected on demand via [MCP elicitation](https://spec.modelcontextprotocol.io/specification/2025-11-25/client/elicitation/).
147
+
148
+ ### `OAuthProvider` — third-party OAuth 2.0
149
+
150
+ Gates a tool behind a full Authorization Code + PKCE flow with a third-party provider. The MCP client opens the provider's login page; the tool call suspends until the callback fires.
151
+
152
+ ```python
153
+ from mcpauthkit import OAuthProvider
154
+
155
+ github_oauth = OAuthProvider.from_standard_oauth2(
156
+ name="github",
157
+ authorization_url="https://github.com/login/oauth/authorize",
158
+ token_url="https://github.com/login/oauth/access_token",
159
+ client_id=os.environ["GITHUB_CLIENT_ID"],
160
+ client_secret=os.environ["GITHUB_CLIENT_SECRET"],
161
+ scope="read:user repo",
162
+ redirect_uri=f"{SERVER_URL}/github/callback",
163
+ user_context=current_user,
164
+ )
165
+ github_oauth.register(app) # registers GET /github/callback on the FastAPI app
166
+
167
+ @mcp.tool(description="List open PRs")
168
+ @github_oauth.require_token()
169
+ async def list_prs(ctx: Context, repo: str) -> str:
170
+ token = github_oauth.get_token() # guaranteed non-None inside the decorator
171
+ ...
172
+ ```
173
+
174
+ Key methods:
175
+
176
+ | Method | Description |
177
+ |---|---|
178
+ | `from_standard_oauth2(...)` | Factory for any standard OAuth 2.0 provider |
179
+ | `register(app)` | Register the callback route on the FastAPI app |
180
+ | `require_token(*, fail_fast=False)` | Decorator — elicits token if not cached, or raises immediately |
181
+ | `get_token()` | Return the cached access token for the current user (or `None`) |
182
+ | `invalidate_token(sub)` | Evict a user's cached token |
183
+ | `.callback_path` | The redirect URI path (add to `open_paths`) |
184
+
185
+ ### `CredentialsProvider` — PAT / API key form
186
+
187
+ Serves a self-hosted HTML form where the user enters credentials (PATs, API keys, etc.). Values are stored server-side, keyed by the primary OIDC `sub`. The form optionally renders a Markdown how-to guide (client-side via [marked.js](https://marked.js.org/)).
188
+
189
+ ```python
190
+ from mcpauthkit import CredentialsProvider
191
+
192
+ confluence_creds = CredentialsProvider(
193
+ name="confluence",
194
+ variables={
195
+ "pat": {
196
+ "label": "Personal Access Token",
197
+ "type": "password",
198
+ "placeholder": "Your Confluence PAT",
199
+ },
200
+ },
201
+ user_context=current_user,
202
+ server_base_url=SERVER_URL,
203
+ doc="docs/confluence_token_how.md", # optional — rendered above the form
204
+ )
205
+ confluence_creds.register(app)
206
+
207
+ @mcp.tool(description="List Confluence pages")
208
+ @confluence_creds.require_credentials()
209
+ async def list_pages(ctx: Context, space: str) -> str:
210
+ creds = confluence_creds.get_credentials() # {"pat": "..."}
211
+ ...
212
+ ```
213
+
214
+ Key methods / properties:
215
+
216
+ | Member | Description |
217
+ |---|---|
218
+ | `register(app)` | Register entry + submit routes on the FastAPI app |
219
+ | `require_credentials(*, fail_fast=False)` | Decorator — elicits credentials if not cached |
220
+ | `get_credentials()` | Return the cached credentials dict for the current user |
221
+ | `invalidate_credentials(sub)` | Evict a user's cached credentials |
222
+ | `.open_paths` | Tuple of paths to add to `JwtAuthMiddleware` `open_paths` |
223
+
224
+ ---
225
+
226
+ ## MCP mount
227
+
228
+ The FastMCP sub-app must be mounted at `/` **after** all routes are registered. Its internal Starlette router exposes the MCP endpoint at `/mcp`:
229
+
230
+ ```python
231
+ # All routes (include_router, register, @app.get) must come before this line
232
+ app.mount("/", app=mcp.streamable_http_app())
233
+ ```
234
+
235
+ The MCP client connects to `http://<host>:<port>/mcp`.
236
+
237
+ ---
238
+
239
+ ## HTML templates
240
+
241
+ All browser-facing pages use Jinja2 templates in `mcpauthkit/providers/templates/`. Every page extends `base.html` which provides:
242
+
243
+ - A blue "MCP Authentication 🔒" top bar
244
+ - A centered card layout
245
+ - Minimal inline CSS (no external CDN dependencies except `marked.js` on the credentials entry page)
246
+
247
+ ---
248
+
249
+ ## Getting started
250
+
251
+ A full working example (Docker Compose with Redis + Keycloak, a GitHub OAuth tool, and a Confluence credentials tool) lives in the companion repo:
252
+
253
+ **[mcp-authkit-quickstart](https://github.com/masterela/mcp-authkit-quickstart)**
254
+
255
+ ---
256
+
257
+ ## Storage backends
258
+
259
+ | Mode | Class | Notes |
260
+ |---|---|---|
261
+ | `memory` (default) | `MemoryTokenStore` / `MemoryPendingStore` | In-process. Tokens lost on restart. Suitable for development. |
262
+ | `file` | `FileTokenStore` / `FilePendingStore` | Fernet-encrypted JSON files. Good for single-instance deployments. |
263
+ | `redis` | `RedisTokenStore` / `RedisPendingStore` | Async Redis. Requires `pip install "mcp-authkit[redis]"`. Use for multi-replica deployments. |
264
+
265
+ Select a backend via the `TOKEN_STORAGE_MODE` environment variable (`memory` / `file` / `redis`), or call `create_stores()` directly.
266
+
267
+ ---
268
+
269
+ ## Dependencies
270
+
271
+ | Package | Purpose |
272
+ |---|---|
273
+ | `fastapi` | HTTP framework |
274
+ | `mcp>=1.6` | MCP server SDK (FastMCP) |
275
+ | `starlette` | ASGI primitives, `BaseHTTPMiddleware` |
276
+ | `python-jose[cryptography]` | JWT decoding and JWKS validation |
277
+ | `httpx` | Async HTTP client (token exchange, OIDC discovery) |
278
+ | `jinja2` | HTML template rendering |
279
+ | `cryptography` | Fernet encryption for file and Redis stores |
280
+
281
+ ---
282
+
283
+ ## Contributing
284
+
285
+ ```bash
286
+ # Install dev dependencies
287
+ uv sync --group dev
288
+
289
+ # Lint + type-check
290
+ uv run ruff check mcpauthkit/ tests/
291
+ uv run mypy mcpauthkit/
292
+
293
+ # Tests with coverage
294
+ uv run pytest --cov=mcpauthkit --cov-report=term-missing -q
295
+ ```
296
+
297
+ See [CHANGELOG.md](CHANGELOG.md) for release history.
@@ -0,0 +1,26 @@
1
+ mcp_authkit-0.1.0.dist-info/licenses/LICENSE,sha256=cLo2tx65aSsi1RZJ8-ueSKnnXtV_wM5rhFl2_3ErIsQ,1066
2
+ mcpauthkit/__init__.py,sha256=Ynh57kMTXjXYPF8KtE_4Ie8redEqT46iQJT1PrlW5l4,2947
3
+ mcpauthkit/auth_middleware.py,sha256=zDYVomQRtnpUimyVCwVPjxPyW0aRBXrGPb_3S_w5Rv0,6428
4
+ mcpauthkit/auth_routes.py,sha256=HRvrwigc-Y7r3Kf9qCYvKkt0UWKiTBKktgiQpRrB7TY,4632
5
+ mcpauthkit/jwt_validator.py,sha256=oBLfI7yzIsAR9zr5hma0bgRZQiywIyItYBudwkIWOGw,4819
6
+ mcpauthkit/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ mcpauthkit/providers/__init__.py,sha256=IkrzmtGCx8V_n8ZM1zE6uLfPU5JbXc3ZF3EBgp9pfBA,148
8
+ mcpauthkit/providers/credentials_provider.py,sha256=_4PQwbyKYlDBDiEudEdYb3ovnCbuucGA4nehKjFq2yY,20909
9
+ mcpauthkit/providers/oauth_provider.py,sha256=m8zvX5rjzsChfQoYUGnmDMYh3ViYFhhKZLeAsLlL56g,28855
10
+ mcpauthkit/providers/templates/base.html,sha256=3rebGrrpNVqUJS7HXBJdScl6w1cUOECjopl2OgPQfh4,3410
11
+ mcpauthkit/providers/templates/credentials_entry.html,sha256=c0ZuDDas7ZwVmOBSHsUFdeyxjWmgBKx_A5LbbBvokeQ,4281
12
+ mcpauthkit/providers/templates/credentials_error.html,sha256=SW3UG7X9Pl0xp6ZMq3fZ7gwHtMPf5c2gxYvTPltF9nY,229
13
+ mcpauthkit/providers/templates/credentials_success.html,sha256=dUW_6Q_-z9eEwos_GBnJ0LbhJPixaUr5k7beK2NrdIM,301
14
+ mcpauthkit/providers/templates/oauth_error.html,sha256=Gu3sYvjyYOM0Nt9HJFes1DInxlL97YlsaiyZIGyBetY,378
15
+ mcpauthkit/providers/templates/oauth_success.html,sha256=hdDlSpVKmiZSUaDVpbsrLXqm-VB_kN04D2Y4GUtQ8Dg,391
16
+ mcpauthkit/store/__init__.py,sha256=Fy3KPhzIjReE97ilwY8FW0gO1dI3keYJi6P6dm1yuLI,1337
17
+ mcpauthkit/store/base.py,sha256=QkXxaxOxn2p9a4lMYPc3X90BbmeYUYY8l24rsh5KE24,2972
18
+ mcpauthkit/store/encryption.py,sha256=y6ugmzYqAGI2W90tO8Pzkyzr5T_d-Esa2xRQPsbpz3E,3252
19
+ mcpauthkit/store/factory.py,sha256=RhrRHJRn8crFs5jVPgIJcp2VcK9xVOcbfAhkV8IbaSY,4644
20
+ mcpauthkit/store/file_store.py,sha256=-awzOvNz22zGWvJNHwqyuRd29l6GX_V7K6ffqqr_NSg,6855
21
+ mcpauthkit/store/memory.py,sha256=64dAN0Cb5uV3abIDVOG-4Rsoy7L-dOhQ4PJwZwLeCmk,4144
22
+ mcpauthkit/store/redis_store.py,sha256=KexRoJG68DZo_5mPUykdEMW0I2b7L5zW5YYy77fUNOk,6271
23
+ mcp_authkit-0.1.0.dist-info/METADATA,sha256=n330q1pe1enq3wHMzFKcsxF4B0bW3PQXXJ9_sjOX_kI,11877
24
+ mcp_authkit-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
25
+ mcp_authkit-0.1.0.dist-info/top_level.txt,sha256=HNdSkM4LlUsTFTFZOewB2XFKc4H19B6rtasId0cGTYQ,11
26
+ mcp_authkit-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 masterela
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
+ mcpauthkit
mcpauthkit/__init__.py ADDED
@@ -0,0 +1,81 @@
1
+ """
2
+ mcpauthkit — MCP authentication elicitation library
3
+ =====================================================
4
+
5
+ Provides two providers for gating MCP tools behind credential acquisition:
6
+
7
+ OAuthProvider
8
+ Gates tools behind a third-party OAuth 2.0 Authorization Code flow
9
+ (GitHub, Google, Jira, Entra, etc.). Uses URL mode elicitation so
10
+ the client opens the provider's login page; the tool call stays open
11
+ until the callback fires (or raises immediately in fail-fast mode).
12
+
13
+ Quick start::
14
+
15
+ from mcpauthkit import OAuthProvider
16
+
17
+ github = OAuthProvider.from_standard_oauth2(
18
+ name="github",
19
+ authorization_url="https://github.com/login/oauth/authorize",
20
+ token_url="https://github.com/login/oauth/access_token",
21
+ client_id=..., client_secret=..., scope="read:user repo",
22
+ redirect_uri="http://localhost:8005/github/callback",
23
+ user_context=current_user,
24
+ )
25
+ github.register(app) # register callback route
26
+ _OPEN_PATHS = (..., github.callback_path)
27
+
28
+ @mcp.tool(description="...")
29
+ @github.require_token()
30
+ async def my_tool(ctx: Context, ...) -> str:
31
+ token = github.get_token() # guaranteed non-None here
32
+
33
+ CredentialsProvider
34
+ Gates tools behind a PAT / API-key form served by the MCP server
35
+ itself. The client opens an internal URL where the user fills in
36
+ credentials; values are stored server-side and never passed through
37
+ the AI assistant.
38
+
39
+ Quick start::
40
+
41
+ from mcpauthkit import CredentialsProvider
42
+
43
+ creds = CredentialsProvider(
44
+ name="confluence",
45
+ variables={"pat": {"label": "PAT", "type": "password", ...}},
46
+ user_context=current_user,
47
+ server_base_url="http://localhost:8005",
48
+ doc="/path/to/how-to.md", # optional Markdown guide
49
+ )
50
+ creds.register(app)
51
+ _OPEN_PATHS = (..., *creds.open_paths)
52
+
53
+ @mcp.tool(description="...")
54
+ @creds.require_credentials()
55
+ async def my_tool(ctx: Context, ...) -> str:
56
+ c = creds.get_credentials() # {"pat": "...", ...}
57
+
58
+ auth_routes
59
+ Generic well-known OAuth metadata endpoints and a DCR façade::
60
+
61
+ from mcpauthkit.auth_routes import register_oauth_meta_routes
62
+ register_oauth_meta_routes(app, server_base_url=..., keycloak_url=..., ...)
63
+
64
+ store
65
+ Pluggable encrypted storage backends::
66
+
67
+ from mcpauthkit.store import create_stores
68
+ token_store, pending_store = create_stores() # reads TOKEN_STORAGE_MODE env var
69
+ # pass token_store / pending_store into OAuthProvider / CredentialsProvider
70
+ """
71
+
72
+ from .providers import CredentialsProvider, OAuthProvider
73
+ from .store import PendingStore, TokenStore, create_stores
74
+
75
+ __all__ = [
76
+ "CredentialsProvider",
77
+ "OAuthProvider",
78
+ "PendingStore",
79
+ "TokenStore",
80
+ "create_stores",
81
+ ]
@@ -0,0 +1,176 @@
1
+ """
2
+ mcpauthkit.auth_middleware — JWT bearer middleware for FastAPI / MCP servers.
3
+
4
+ Validates every incoming request against an OIDC provider's JWKS endpoint
5
+ and populates a ``current_user`` ContextVar so tools can read the caller's
6
+ claims.
7
+
8
+ Usage
9
+ -----
10
+ from mcpauthkit.auth_middleware import JwtAuthMiddleware
11
+
12
+ app.add_middleware(
13
+ JwtAuthMiddleware,
14
+ issuer_url=settings.keycloak_url,
15
+ current_user=current_user,
16
+ server_base_url="http://localhost:8005",
17
+ open_paths=(
18
+ "/.well-known", "/health", "/register",
19
+ github_oauth.callback_path,
20
+ *confluence_creds.open_paths,
21
+ ),
22
+ )
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import logging
28
+ from contextvars import ContextVar
29
+ from typing import cast
30
+
31
+ from fastapi.responses import JSONResponse
32
+ from starlette.middleware.base import BaseHTTPMiddleware
33
+ from starlette.requests import Request
34
+ from starlette.responses import Response
35
+
36
+ from .jwt_validator import JwtFailReason, validate_jwt
37
+
38
+ logger = logging.getLogger(__name__)
39
+
40
+
41
+ class JwtAuthMiddleware(BaseHTTPMiddleware):
42
+ """
43
+ JWT bearer middleware compatible with ``app.add_middleware()``.
44
+
45
+ Validates the ``Authorization: Bearer <token>`` header on every
46
+ non-open request using OIDC JWKS discovery. Works with any standard
47
+ OIDC provider (Keycloak, Okta, Entra ID, Duende, Auth0, …).
48
+
49
+ Parameters
50
+ ----------
51
+ issuer_url
52
+ Base URL of the OIDC issuer,
53
+ e.g. ``"http://localhost:8889/realms/mcp-poc5"`` or
54
+ ``"https://login.microsoftonline.com/{tenant}/v2.0"``.
55
+ current_user
56
+ ContextVar populated with the verified JWT claims dict on each
57
+ authenticated request.
58
+ server_base_url
59
+ Used to build the ``WWW-Authenticate`` realm / resource-metadata URIs.
60
+ open_paths
61
+ Tuple of path prefixes that bypass authentication (browser redirects,
62
+ health checks, well-known endpoints, provider callbacks, etc.).
63
+ """
64
+
65
+ def __init__(
66
+ self,
67
+ app,
68
+ *,
69
+ issuer_url: str,
70
+ current_user: ContextVar[dict | None],
71
+ server_base_url: str,
72
+ open_paths: tuple[str, ...] = (),
73
+ ) -> None:
74
+ """Initialise the middleware; see class docstring for parameter descriptions."""
75
+ super().__init__(app)
76
+ self._issuer_url = issuer_url
77
+ self._current_user = current_user
78
+ self._base = server_base_url.rstrip("/")
79
+ self._open_paths = open_paths
80
+
81
+ async def dispatch(self, request: Request, call_next) -> Response:
82
+ """Process a single request: validate the Bearer token or pass through open paths.
83
+
84
+ Returns a ``401 Unauthorized`` JSON response (with a standards-compliant
85
+ ``WWW-Authenticate: Bearer`` header) when the token is absent, malformed,
86
+ has the wrong issuer, or carries an invalid signature. Returns a
87
+ ``401`` with ``error=invalid_token`` when the token has expired, so
88
+ the client can use its refresh token.
89
+
90
+ Parameters
91
+ ----------
92
+ request
93
+ The incoming Starlette / FastAPI request.
94
+ call_next
95
+ The next ASGI handler in the middleware chain.
96
+ """
97
+ logger.debug(
98
+ "→ %s %s auth=%s open=%s",
99
+ request.method,
100
+ request.url.path,
101
+ bool(request.headers.get("Authorization")),
102
+ self._is_open(request.url.path),
103
+ )
104
+
105
+ if request.method == "OPTIONS" or self._is_open(request.url.path):
106
+ return cast(Response, await call_next(request))
107
+
108
+ auth_header = request.headers.get("Authorization", "")
109
+ if not auth_header.startswith("Bearer "):
110
+ logger.debug("→ no/bad Bearer → 401")
111
+ return self._unauthorized()
112
+
113
+ token = auth_header[len("Bearer ") :]
114
+ claims, fail_reason = await validate_jwt(token, self._issuer_url)
115
+ if claims is None:
116
+ logger.debug("→ JWT invalid (reason=%s) → 401", fail_reason)
117
+ return (
118
+ self._token_expired()
119
+ if fail_reason is JwtFailReason.EXPIRED
120
+ else self._unauthorized()
121
+ )
122
+
123
+ sub = claims.get("sub") or claims.get("preferred_username", "unknown")
124
+ logger.info(
125
+ "Authenticated: sub=%s preferred_username=%s",
126
+ sub,
127
+ claims.get("preferred_username"),
128
+ )
129
+ self._current_user.set(
130
+ {
131
+ "sub": sub,
132
+ "preferred_username": claims.get("preferred_username"),
133
+ "email": claims.get("email"),
134
+ "name": claims.get("name"),
135
+ "iss": claims.get("iss"),
136
+ "exp": claims.get("exp"),
137
+ }
138
+ )
139
+ return cast(Response, await call_next(request))
140
+
141
+ # ── Internal ─────────────────────────────────────────────────────────────────
142
+
143
+ def _is_open(self, path: str) -> bool:
144
+ """Return True if *path* starts with any of the configured open path prefixes."""
145
+ return any(path.startswith(p) for p in self._open_paths)
146
+
147
+ def _unauthorized(self) -> JSONResponse:
148
+ """No token — client must start a fresh PKCE flow (RFC 6750 §3.1)."""
149
+ return JSONResponse(
150
+ status_code=401,
151
+ headers={
152
+ "WWW-Authenticate": (
153
+ f'Bearer realm="{self._base}/mcp",'
154
+ f' resource_metadata="{self._base}/.well-known/oauth-protected-resource"'
155
+ )
156
+ },
157
+ content={"error": "unauthorized"},
158
+ )
159
+
160
+ def _token_expired(self) -> JSONResponse:
161
+ """Token present but expired — client should refresh (RFC 6750 §3.1)."""
162
+ return JSONResponse(
163
+ status_code=401,
164
+ headers={
165
+ "WWW-Authenticate": (
166
+ f'Bearer realm="{self._base}/mcp",'
167
+ f' resource_metadata="{self._base}/.well-known/oauth-protected-resource",'
168
+ f' error="invalid_token",'
169
+ f' error_description="The access token has expired"'
170
+ )
171
+ },
172
+ content={
173
+ "error": "invalid_token",
174
+ "error_description": "The access token has expired",
175
+ },
176
+ )