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.
- mcp_authkit-0.1.0.dist-info/METADATA +297 -0
- mcp_authkit-0.1.0.dist-info/RECORD +26 -0
- mcp_authkit-0.1.0.dist-info/WHEEL +5 -0
- mcp_authkit-0.1.0.dist-info/licenses/LICENSE +21 -0
- mcp_authkit-0.1.0.dist-info/top_level.txt +1 -0
- mcpauthkit/__init__.py +81 -0
- mcpauthkit/auth_middleware.py +176 -0
- mcpauthkit/auth_routes.py +130 -0
- mcpauthkit/jwt_validator.py +154 -0
- mcpauthkit/providers/__init__.py +4 -0
- mcpauthkit/providers/credentials_provider.py +522 -0
- mcpauthkit/providers/oauth_provider.py +690 -0
- mcpauthkit/providers/templates/base.html +109 -0
- mcpauthkit/providers/templates/credentials_entry.html +150 -0
- mcpauthkit/providers/templates/credentials_error.html +8 -0
- mcpauthkit/providers/templates/credentials_success.html +10 -0
- mcpauthkit/providers/templates/oauth_error.html +9 -0
- mcpauthkit/providers/templates/oauth_success.html +11 -0
- mcpauthkit/py.typed +0 -0
- mcpauthkit/store/__init__.py +36 -0
- mcpauthkit/store/base.py +84 -0
- mcpauthkit/store/encryption.py +88 -0
- mcpauthkit/store/factory.py +120 -0
- mcpauthkit/store/file_store.py +174 -0
- mcpauthkit/store/memory.py +110 -0
- mcpauthkit/store/redis_store.py +165 -0
|
@@ -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
|
+
[](https://github.com/masterela/mcp-authkit/actions/workflows/ci.yml)
|
|
30
|
+
[](https://codecov.io/gh/masterela/mcp-authkit)
|
|
31
|
+
[](https://masterela.github.io/mcp-authkit/)
|
|
32
|
+
[](https://pypi.org/project/mcp-authkit/)
|
|
33
|
+
[](https://pypi.org/project/mcp-authkit/)
|
|
34
|
+
[](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,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
|
+
)
|