mcp-authflow 0.1.0__tar.gz
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_authflow-0.1.0/.github/workflows/ci.yml +43 -0
- mcp_authflow-0.1.0/.github/workflows/publish.yml +21 -0
- mcp_authflow-0.1.0/.gitignore +15 -0
- mcp_authflow-0.1.0/.python-version +1 -0
- mcp_authflow-0.1.0/LICENSE +21 -0
- mcp_authflow-0.1.0/PKG-INFO +306 -0
- mcp_authflow-0.1.0/README.md +274 -0
- mcp_authflow-0.1.0/mcp_auth_framework/__init__.py +81 -0
- mcp_authflow-0.1.0/mcp_auth_framework/cors.py +63 -0
- mcp_authflow-0.1.0/mcp_auth_framework/py.typed +0 -0
- mcp_authflow-0.1.0/mcp_auth_framework/rate_limiting.py +59 -0
- mcp_authflow-0.1.0/mcp_auth_framework/responses.py +140 -0
- mcp_authflow-0.1.0/mcp_auth_framework/storage/__init__.py +30 -0
- mcp_authflow-0.1.0/mcp_auth_framework/storage/base.py +131 -0
- mcp_authflow-0.1.0/mcp_auth_framework/storage/memory.py +234 -0
- mcp_authflow-0.1.0/mcp_auth_framework/storage/postgres.py +324 -0
- mcp_authflow-0.1.0/mcp_auth_framework/validation.py +80 -0
- mcp_authflow-0.1.0/pyproject.toml +72 -0
- mcp_authflow-0.1.0/tests/test_cors.py +129 -0
- mcp_authflow-0.1.0/tests/test_memory_storage.py +252 -0
- mcp_authflow-0.1.0/tests/test_postgres_storage.py +505 -0
- mcp_authflow-0.1.0/tests/test_rate_limiting.py +230 -0
- mcp_authflow-0.1.0/tests/test_responses.py +378 -0
- mcp_authflow-0.1.0/tests/test_validation.py +141 -0
- mcp_authflow-0.1.0/uv.lock +1615 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
lint:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
steps:
|
|
13
|
+
- uses: actions/checkout@v4
|
|
14
|
+
- uses: astral-sh/setup-uv@v6
|
|
15
|
+
with:
|
|
16
|
+
enable-cache: true
|
|
17
|
+
- run: uv sync --frozen
|
|
18
|
+
- run: uv run ruff check .
|
|
19
|
+
- run: uv run ruff format --check .
|
|
20
|
+
|
|
21
|
+
typecheck:
|
|
22
|
+
runs-on: ubuntu-latest
|
|
23
|
+
steps:
|
|
24
|
+
- uses: actions/checkout@v4
|
|
25
|
+
- uses: astral-sh/setup-uv@v6
|
|
26
|
+
with:
|
|
27
|
+
enable-cache: true
|
|
28
|
+
- run: uv sync --frozen
|
|
29
|
+
- run: uv run pyright
|
|
30
|
+
|
|
31
|
+
test:
|
|
32
|
+
runs-on: ubuntu-latest
|
|
33
|
+
strategy:
|
|
34
|
+
matrix:
|
|
35
|
+
python-version: ["3.11", "3.12", "3.13"]
|
|
36
|
+
steps:
|
|
37
|
+
- uses: actions/checkout@v4
|
|
38
|
+
- uses: astral-sh/setup-uv@v6
|
|
39
|
+
with:
|
|
40
|
+
enable-cache: true
|
|
41
|
+
- run: uv python install ${{ matrix.python-version }}
|
|
42
|
+
- run: uv sync --frozen --python ${{ matrix.python-version }}
|
|
43
|
+
- run: uv run pytest --tb=short
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*"
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
id-token: write
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
publish:
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
environment: pypi
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
- uses: astral-sh/setup-uv@v6
|
|
18
|
+
with:
|
|
19
|
+
enable-cache: true
|
|
20
|
+
- run: uv build
|
|
21
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.11
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 brooksmcmillin
|
|
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,306 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mcp-authflow
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: OAuth 2.0 Authorization Server framework for MCP (Model Context Protocol) servers
|
|
5
|
+
Project-URL: Homepage, https://github.com/brooksmcmillin/mcpauth
|
|
6
|
+
Project-URL: Documentation, https://github.com/brooksmcmillin/mcpauth#readme
|
|
7
|
+
Project-URL: Repository, https://github.com/brooksmcmillin/mcpauth
|
|
8
|
+
Project-URL: Issues, https://github.com/brooksmcmillin/mcpauth/issues
|
|
9
|
+
Author: Brooks McMillin
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Framework :: AsyncIO
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
21
|
+
Classifier: Topic :: Security
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.11
|
|
24
|
+
Requires-Dist: aiohttp>=3.13.0
|
|
25
|
+
Requires-Dist: mcp>=1.24.0
|
|
26
|
+
Requires-Dist: pydantic-settings>=2.0.0
|
|
27
|
+
Requires-Dist: pydantic>=2.0.0
|
|
28
|
+
Requires-Dist: starlette>=0.40.0
|
|
29
|
+
Provides-Extra: postgres
|
|
30
|
+
Requires-Dist: asyncpg>=0.31.0; extra == 'postgres'
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# mcp-authflow
|
|
34
|
+
|
|
35
|
+
OAuth 2.0 Authorization Server framework for [MCP](https://modelcontextprotocol.io/) servers. Issue and manage tokens that protect MCP tool access.
|
|
36
|
+
|
|
37
|
+
Pair with [mcp-authflow-resource](https://github.com/brooksmcmillin/mcpauth-resource) on the resource server side.
|
|
38
|
+
|
|
39
|
+
## Features
|
|
40
|
+
|
|
41
|
+
- **Token storage** with PostgreSQL and in-memory backends
|
|
42
|
+
- **RFC 6749** standardized OAuth error responses
|
|
43
|
+
- **Sliding-window rate limiting** for token endpoints
|
|
44
|
+
- **Input validation** for client IDs, scopes, and PKCE parameters
|
|
45
|
+
- **CORS helpers** with origin allowlisting
|
|
46
|
+
- **Async-first** design, built on Starlette
|
|
47
|
+
|
|
48
|
+
## Installation
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install mcp-authflow
|
|
52
|
+
|
|
53
|
+
# With PostgreSQL token storage (production)
|
|
54
|
+
pip install mcp-authflow[postgres]
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Quick Start
|
|
58
|
+
|
|
59
|
+
Build an OAuth authorization server that issues tokens for MCP clients:
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
import secrets
|
|
63
|
+
import time
|
|
64
|
+
from contextlib import asynccontextmanager
|
|
65
|
+
|
|
66
|
+
from starlette.applications import Starlette
|
|
67
|
+
from starlette.requests import Request
|
|
68
|
+
from starlette.responses import JSONResponse
|
|
69
|
+
from starlette.routing import Route
|
|
70
|
+
|
|
71
|
+
from mcp_auth_framework.rate_limiting import SlidingWindowRateLimiter
|
|
72
|
+
from mcp_auth_framework.responses import invalid_request, rate_limit_exceeded
|
|
73
|
+
from mcp_auth_framework.storage import MemoryTokenStorage
|
|
74
|
+
from mcp_auth_framework.validation import parse_scope_field, validate_client_id
|
|
75
|
+
|
|
76
|
+
# --- Setup ---
|
|
77
|
+
|
|
78
|
+
storage = MemoryTokenStorage() # Use PostgresTokenStorage for production
|
|
79
|
+
limiter = SlidingWindowRateLimiter(requests_per_window=60, window_seconds=3600)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# --- Token endpoint ---
|
|
83
|
+
|
|
84
|
+
async def token_endpoint(request: Request) -> JSONResponse:
|
|
85
|
+
form = await request.form()
|
|
86
|
+
client_id = str(form.get("client_id", ""))
|
|
87
|
+
|
|
88
|
+
# Rate limit per client
|
|
89
|
+
if not limiter.is_allowed(client_id):
|
|
90
|
+
return rate_limit_exceeded(
|
|
91
|
+
"Too many requests",
|
|
92
|
+
retry_after=limiter.get_retry_after(client_id),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# Validate client
|
|
96
|
+
if not validate_client_id(client_id):
|
|
97
|
+
return invalid_request("Invalid client_id format")
|
|
98
|
+
|
|
99
|
+
# Issue token
|
|
100
|
+
token = secrets.token_urlsafe(32)
|
|
101
|
+
scopes = parse_scope_field(form.get("scope"))
|
|
102
|
+
expires_at = int(time.time()) + 3600
|
|
103
|
+
|
|
104
|
+
await storage.store_token(
|
|
105
|
+
token=token,
|
|
106
|
+
client_id=client_id,
|
|
107
|
+
scopes=scopes.split(),
|
|
108
|
+
expires_at=expires_at,
|
|
109
|
+
resource=str(form.get("resource", "")),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
return JSONResponse({
|
|
113
|
+
"access_token": token,
|
|
114
|
+
"token_type": "bearer",
|
|
115
|
+
"expires_in": 3600,
|
|
116
|
+
"scope": scopes,
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# --- Introspection endpoint (called by resource servers) ---
|
|
121
|
+
|
|
122
|
+
async def introspect_endpoint(request: Request) -> JSONResponse:
|
|
123
|
+
form = await request.form()
|
|
124
|
+
token = str(form.get("token", ""))
|
|
125
|
+
|
|
126
|
+
token_data = await storage.load_token(token)
|
|
127
|
+
if not token_data or token_data["expires_at"] < time.time():
|
|
128
|
+
return JSONResponse({"active": False})
|
|
129
|
+
|
|
130
|
+
return JSONResponse({
|
|
131
|
+
"active": True,
|
|
132
|
+
"client_id": token_data["client_id"],
|
|
133
|
+
"scope": " ".join(token_data["scopes"]),
|
|
134
|
+
"exp": token_data["expires_at"],
|
|
135
|
+
"aud": token_data.get("resource", ""),
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@asynccontextmanager
|
|
140
|
+
async def lifespan(app):
|
|
141
|
+
await storage.initialize()
|
|
142
|
+
yield
|
|
143
|
+
await storage.close()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
app = Starlette(
|
|
147
|
+
routes=[
|
|
148
|
+
Route("/token", token_endpoint, methods=["POST"]),
|
|
149
|
+
Route("/introspect", introspect_endpoint, methods=["POST"]),
|
|
150
|
+
],
|
|
151
|
+
lifespan=lifespan,
|
|
152
|
+
)
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Run with: `uvicorn myapp:app --port 8000`
|
|
156
|
+
|
|
157
|
+
## Architecture
|
|
158
|
+
|
|
159
|
+
```
|
|
160
|
+
MCP Client (Claude, etc.)
|
|
161
|
+
|
|
|
162
|
+
1. Authorization request
|
|
163
|
+
|
|
|
164
|
+
v
|
|
165
|
+
+---------------------+
|
|
166
|
+
| Auth Server | <-- this package
|
|
167
|
+
| (mcp-authflow) |
|
|
168
|
+
| |
|
|
169
|
+
| /token | 2. Issues access token
|
|
170
|
+
| /introspect | 4. Validates token
|
|
171
|
+
+---------------------+
|
|
172
|
+
^
|
|
173
|
+
|
|
|
174
|
+
4. Token introspection (RFC 7662)
|
|
175
|
+
|
|
|
176
|
+
+---------------------+
|
|
177
|
+
| Resource Server | <-- mcp-authflow-resource
|
|
178
|
+
| (MCP tools) |
|
|
179
|
+
| |
|
|
180
|
+
| 3. Client calls |
|
|
181
|
+
| MCP tools with |
|
|
182
|
+
| Bearer token |
|
|
183
|
+
+---------------------+
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
1. MCP client authenticates with the auth server
|
|
187
|
+
2. Auth server issues an access token (stored in PostgreSQL or memory)
|
|
188
|
+
3. Client calls MCP tools on the resource server with the Bearer token
|
|
189
|
+
4. Resource server validates the token by calling the auth server's `/introspect` endpoint
|
|
190
|
+
|
|
191
|
+
## API Reference
|
|
192
|
+
|
|
193
|
+
### Token Storage
|
|
194
|
+
|
|
195
|
+
Abstract base class with two implementations:
|
|
196
|
+
|
|
197
|
+
```python
|
|
198
|
+
from mcp_auth_framework.storage import MemoryTokenStorage, PostgresTokenStorage
|
|
199
|
+
|
|
200
|
+
# In-memory (development/testing)
|
|
201
|
+
storage = MemoryTokenStorage()
|
|
202
|
+
|
|
203
|
+
# PostgreSQL (production) -- requires `postgres` extra
|
|
204
|
+
storage = PostgresTokenStorage(database_url="postgresql://user:pass@host/db")
|
|
205
|
+
# Or reads DATABASE_URL env var if no argument provided
|
|
206
|
+
storage = PostgresTokenStorage()
|
|
207
|
+
|
|
208
|
+
await storage.initialize() # Create tables / prepare connections
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
**Storage interface:**
|
|
212
|
+
|
|
213
|
+
| Method | Description |
|
|
214
|
+
|--------|-------------|
|
|
215
|
+
| `store_token(token, client_id, scopes, expires_at, resource?, user_id?)` | Store an access token |
|
|
216
|
+
| `load_token(token) -> dict \| None` | Look up a token |
|
|
217
|
+
| `delete_token(token)` | Revoke a token |
|
|
218
|
+
| `cleanup_expired_tokens() -> int` | Purge expired tokens, returns count |
|
|
219
|
+
| `get_token_count() -> int` | Count active tokens |
|
|
220
|
+
| `store_refresh_token(...)` | Store a refresh token (same interface) |
|
|
221
|
+
| `load_refresh_token(token) -> dict \| None` | Look up a refresh token |
|
|
222
|
+
| `delete_refresh_token(token)` | Revoke a refresh token |
|
|
223
|
+
| `cleanup_expired_refresh_tokens() -> int` | Purge expired refresh tokens, returns count |
|
|
224
|
+
|
|
225
|
+
Token data returned by `load_token()`:
|
|
226
|
+
|
|
227
|
+
```python
|
|
228
|
+
{
|
|
229
|
+
"token": str,
|
|
230
|
+
"client_id": str,
|
|
231
|
+
"scopes": list[str],
|
|
232
|
+
"resource": str | None, # RFC 8707 resource binding
|
|
233
|
+
"expires_at": int, # Unix timestamp
|
|
234
|
+
"created_at": int, # Unix timestamp
|
|
235
|
+
"user_id": int | None,
|
|
236
|
+
}
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
### OAuth Error Responses
|
|
240
|
+
|
|
241
|
+
Standardized error helpers following RFC 6749:
|
|
242
|
+
|
|
243
|
+
```python
|
|
244
|
+
from mcp_auth_framework.responses import (
|
|
245
|
+
invalid_request, # 400 - Missing/invalid parameters
|
|
246
|
+
invalid_client, # 401 - Authentication failure
|
|
247
|
+
invalid_grant, # 400 - Expired/invalid code or token
|
|
248
|
+
invalid_scope, # 400 - Scope violation
|
|
249
|
+
slow_down, # 400 - Device flow rate limiting
|
|
250
|
+
rate_limit_exceeded, # 429 - Too many requests
|
|
251
|
+
server_error, # 500 - Internal error
|
|
252
|
+
backend_timeout, # 504 - Upstream timeout
|
|
253
|
+
)
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
Each returns a Starlette `JSONResponse` with the appropriate status code and `Cache-Control: no-store` header.
|
|
257
|
+
|
|
258
|
+
### Rate Limiting
|
|
259
|
+
|
|
260
|
+
```python
|
|
261
|
+
from mcp_auth_framework.rate_limiting import SlidingWindowRateLimiter
|
|
262
|
+
|
|
263
|
+
limiter = SlidingWindowRateLimiter(
|
|
264
|
+
requests_per_window=60, # Max requests per window
|
|
265
|
+
window_seconds=3600, # Window duration (1 hour)
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
if not limiter.is_allowed(client_id):
|
|
269
|
+
retry_after = limiter.get_retry_after(client_id) # Seconds until next allowed request
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
### Input Validation
|
|
273
|
+
|
|
274
|
+
```python
|
|
275
|
+
from mcp_auth_framework.validation import validate_client_id, parse_scope_field
|
|
276
|
+
|
|
277
|
+
validate_client_id("my-client-123") # True (alphanumeric + hyphens/underscores)
|
|
278
|
+
validate_client_id("") # False
|
|
279
|
+
|
|
280
|
+
parse_scope_field("read write") # "read write"
|
|
281
|
+
parse_scope_field(["read", "write"]) # "read write"
|
|
282
|
+
parse_scope_field(None) # "read" (default)
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
### CORS
|
|
286
|
+
|
|
287
|
+
```python
|
|
288
|
+
from mcp_auth_framework.cors import parse_allowed_origins, build_cors_headers
|
|
289
|
+
|
|
290
|
+
# Reads ALLOWED_MCP_ORIGINS env var (comma-separated)
|
|
291
|
+
origins = parse_allowed_origins()
|
|
292
|
+
|
|
293
|
+
# Returns CORS headers if request origin is in allowlist
|
|
294
|
+
headers = build_cors_headers(request, origins)
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
## Configuration
|
|
298
|
+
|
|
299
|
+
| Env Variable | Description | Default |
|
|
300
|
+
|-------------|-------------|---------|
|
|
301
|
+
| `DATABASE_URL` | PostgreSQL connection string (for `PostgresTokenStorage`) | Required for postgres |
|
|
302
|
+
| `ALLOWED_MCP_ORIGINS` | Comma-separated allowed CORS origins | Empty (no CORS) |
|
|
303
|
+
|
|
304
|
+
## License
|
|
305
|
+
|
|
306
|
+
MIT
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
# mcp-authflow
|
|
2
|
+
|
|
3
|
+
OAuth 2.0 Authorization Server framework for [MCP](https://modelcontextprotocol.io/) servers. Issue and manage tokens that protect MCP tool access.
|
|
4
|
+
|
|
5
|
+
Pair with [mcp-authflow-resource](https://github.com/brooksmcmillin/mcpauth-resource) on the resource server side.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- **Token storage** with PostgreSQL and in-memory backends
|
|
10
|
+
- **RFC 6749** standardized OAuth error responses
|
|
11
|
+
- **Sliding-window rate limiting** for token endpoints
|
|
12
|
+
- **Input validation** for client IDs, scopes, and PKCE parameters
|
|
13
|
+
- **CORS helpers** with origin allowlisting
|
|
14
|
+
- **Async-first** design, built on Starlette
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install mcp-authflow
|
|
20
|
+
|
|
21
|
+
# With PostgreSQL token storage (production)
|
|
22
|
+
pip install mcp-authflow[postgres]
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Quick Start
|
|
26
|
+
|
|
27
|
+
Build an OAuth authorization server that issues tokens for MCP clients:
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
import secrets
|
|
31
|
+
import time
|
|
32
|
+
from contextlib import asynccontextmanager
|
|
33
|
+
|
|
34
|
+
from starlette.applications import Starlette
|
|
35
|
+
from starlette.requests import Request
|
|
36
|
+
from starlette.responses import JSONResponse
|
|
37
|
+
from starlette.routing import Route
|
|
38
|
+
|
|
39
|
+
from mcp_auth_framework.rate_limiting import SlidingWindowRateLimiter
|
|
40
|
+
from mcp_auth_framework.responses import invalid_request, rate_limit_exceeded
|
|
41
|
+
from mcp_auth_framework.storage import MemoryTokenStorage
|
|
42
|
+
from mcp_auth_framework.validation import parse_scope_field, validate_client_id
|
|
43
|
+
|
|
44
|
+
# --- Setup ---
|
|
45
|
+
|
|
46
|
+
storage = MemoryTokenStorage() # Use PostgresTokenStorage for production
|
|
47
|
+
limiter = SlidingWindowRateLimiter(requests_per_window=60, window_seconds=3600)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# --- Token endpoint ---
|
|
51
|
+
|
|
52
|
+
async def token_endpoint(request: Request) -> JSONResponse:
|
|
53
|
+
form = await request.form()
|
|
54
|
+
client_id = str(form.get("client_id", ""))
|
|
55
|
+
|
|
56
|
+
# Rate limit per client
|
|
57
|
+
if not limiter.is_allowed(client_id):
|
|
58
|
+
return rate_limit_exceeded(
|
|
59
|
+
"Too many requests",
|
|
60
|
+
retry_after=limiter.get_retry_after(client_id),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# Validate client
|
|
64
|
+
if not validate_client_id(client_id):
|
|
65
|
+
return invalid_request("Invalid client_id format")
|
|
66
|
+
|
|
67
|
+
# Issue token
|
|
68
|
+
token = secrets.token_urlsafe(32)
|
|
69
|
+
scopes = parse_scope_field(form.get("scope"))
|
|
70
|
+
expires_at = int(time.time()) + 3600
|
|
71
|
+
|
|
72
|
+
await storage.store_token(
|
|
73
|
+
token=token,
|
|
74
|
+
client_id=client_id,
|
|
75
|
+
scopes=scopes.split(),
|
|
76
|
+
expires_at=expires_at,
|
|
77
|
+
resource=str(form.get("resource", "")),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
return JSONResponse({
|
|
81
|
+
"access_token": token,
|
|
82
|
+
"token_type": "bearer",
|
|
83
|
+
"expires_in": 3600,
|
|
84
|
+
"scope": scopes,
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# --- Introspection endpoint (called by resource servers) ---
|
|
89
|
+
|
|
90
|
+
async def introspect_endpoint(request: Request) -> JSONResponse:
|
|
91
|
+
form = await request.form()
|
|
92
|
+
token = str(form.get("token", ""))
|
|
93
|
+
|
|
94
|
+
token_data = await storage.load_token(token)
|
|
95
|
+
if not token_data or token_data["expires_at"] < time.time():
|
|
96
|
+
return JSONResponse({"active": False})
|
|
97
|
+
|
|
98
|
+
return JSONResponse({
|
|
99
|
+
"active": True,
|
|
100
|
+
"client_id": token_data["client_id"],
|
|
101
|
+
"scope": " ".join(token_data["scopes"]),
|
|
102
|
+
"exp": token_data["expires_at"],
|
|
103
|
+
"aud": token_data.get("resource", ""),
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@asynccontextmanager
|
|
108
|
+
async def lifespan(app):
|
|
109
|
+
await storage.initialize()
|
|
110
|
+
yield
|
|
111
|
+
await storage.close()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
app = Starlette(
|
|
115
|
+
routes=[
|
|
116
|
+
Route("/token", token_endpoint, methods=["POST"]),
|
|
117
|
+
Route("/introspect", introspect_endpoint, methods=["POST"]),
|
|
118
|
+
],
|
|
119
|
+
lifespan=lifespan,
|
|
120
|
+
)
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Run with: `uvicorn myapp:app --port 8000`
|
|
124
|
+
|
|
125
|
+
## Architecture
|
|
126
|
+
|
|
127
|
+
```
|
|
128
|
+
MCP Client (Claude, etc.)
|
|
129
|
+
|
|
|
130
|
+
1. Authorization request
|
|
131
|
+
|
|
|
132
|
+
v
|
|
133
|
+
+---------------------+
|
|
134
|
+
| Auth Server | <-- this package
|
|
135
|
+
| (mcp-authflow) |
|
|
136
|
+
| |
|
|
137
|
+
| /token | 2. Issues access token
|
|
138
|
+
| /introspect | 4. Validates token
|
|
139
|
+
+---------------------+
|
|
140
|
+
^
|
|
141
|
+
|
|
|
142
|
+
4. Token introspection (RFC 7662)
|
|
143
|
+
|
|
|
144
|
+
+---------------------+
|
|
145
|
+
| Resource Server | <-- mcp-authflow-resource
|
|
146
|
+
| (MCP tools) |
|
|
147
|
+
| |
|
|
148
|
+
| 3. Client calls |
|
|
149
|
+
| MCP tools with |
|
|
150
|
+
| Bearer token |
|
|
151
|
+
+---------------------+
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
1. MCP client authenticates with the auth server
|
|
155
|
+
2. Auth server issues an access token (stored in PostgreSQL or memory)
|
|
156
|
+
3. Client calls MCP tools on the resource server with the Bearer token
|
|
157
|
+
4. Resource server validates the token by calling the auth server's `/introspect` endpoint
|
|
158
|
+
|
|
159
|
+
## API Reference
|
|
160
|
+
|
|
161
|
+
### Token Storage
|
|
162
|
+
|
|
163
|
+
Abstract base class with two implementations:
|
|
164
|
+
|
|
165
|
+
```python
|
|
166
|
+
from mcp_auth_framework.storage import MemoryTokenStorage, PostgresTokenStorage
|
|
167
|
+
|
|
168
|
+
# In-memory (development/testing)
|
|
169
|
+
storage = MemoryTokenStorage()
|
|
170
|
+
|
|
171
|
+
# PostgreSQL (production) -- requires `postgres` extra
|
|
172
|
+
storage = PostgresTokenStorage(database_url="postgresql://user:pass@host/db")
|
|
173
|
+
# Or reads DATABASE_URL env var if no argument provided
|
|
174
|
+
storage = PostgresTokenStorage()
|
|
175
|
+
|
|
176
|
+
await storage.initialize() # Create tables / prepare connections
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
**Storage interface:**
|
|
180
|
+
|
|
181
|
+
| Method | Description |
|
|
182
|
+
|--------|-------------|
|
|
183
|
+
| `store_token(token, client_id, scopes, expires_at, resource?, user_id?)` | Store an access token |
|
|
184
|
+
| `load_token(token) -> dict \| None` | Look up a token |
|
|
185
|
+
| `delete_token(token)` | Revoke a token |
|
|
186
|
+
| `cleanup_expired_tokens() -> int` | Purge expired tokens, returns count |
|
|
187
|
+
| `get_token_count() -> int` | Count active tokens |
|
|
188
|
+
| `store_refresh_token(...)` | Store a refresh token (same interface) |
|
|
189
|
+
| `load_refresh_token(token) -> dict \| None` | Look up a refresh token |
|
|
190
|
+
| `delete_refresh_token(token)` | Revoke a refresh token |
|
|
191
|
+
| `cleanup_expired_refresh_tokens() -> int` | Purge expired refresh tokens, returns count |
|
|
192
|
+
|
|
193
|
+
Token data returned by `load_token()`:
|
|
194
|
+
|
|
195
|
+
```python
|
|
196
|
+
{
|
|
197
|
+
"token": str,
|
|
198
|
+
"client_id": str,
|
|
199
|
+
"scopes": list[str],
|
|
200
|
+
"resource": str | None, # RFC 8707 resource binding
|
|
201
|
+
"expires_at": int, # Unix timestamp
|
|
202
|
+
"created_at": int, # Unix timestamp
|
|
203
|
+
"user_id": int | None,
|
|
204
|
+
}
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
### OAuth Error Responses
|
|
208
|
+
|
|
209
|
+
Standardized error helpers following RFC 6749:
|
|
210
|
+
|
|
211
|
+
```python
|
|
212
|
+
from mcp_auth_framework.responses import (
|
|
213
|
+
invalid_request, # 400 - Missing/invalid parameters
|
|
214
|
+
invalid_client, # 401 - Authentication failure
|
|
215
|
+
invalid_grant, # 400 - Expired/invalid code or token
|
|
216
|
+
invalid_scope, # 400 - Scope violation
|
|
217
|
+
slow_down, # 400 - Device flow rate limiting
|
|
218
|
+
rate_limit_exceeded, # 429 - Too many requests
|
|
219
|
+
server_error, # 500 - Internal error
|
|
220
|
+
backend_timeout, # 504 - Upstream timeout
|
|
221
|
+
)
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
Each returns a Starlette `JSONResponse` with the appropriate status code and `Cache-Control: no-store` header.
|
|
225
|
+
|
|
226
|
+
### Rate Limiting
|
|
227
|
+
|
|
228
|
+
```python
|
|
229
|
+
from mcp_auth_framework.rate_limiting import SlidingWindowRateLimiter
|
|
230
|
+
|
|
231
|
+
limiter = SlidingWindowRateLimiter(
|
|
232
|
+
requests_per_window=60, # Max requests per window
|
|
233
|
+
window_seconds=3600, # Window duration (1 hour)
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
if not limiter.is_allowed(client_id):
|
|
237
|
+
retry_after = limiter.get_retry_after(client_id) # Seconds until next allowed request
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
### Input Validation
|
|
241
|
+
|
|
242
|
+
```python
|
|
243
|
+
from mcp_auth_framework.validation import validate_client_id, parse_scope_field
|
|
244
|
+
|
|
245
|
+
validate_client_id("my-client-123") # True (alphanumeric + hyphens/underscores)
|
|
246
|
+
validate_client_id("") # False
|
|
247
|
+
|
|
248
|
+
parse_scope_field("read write") # "read write"
|
|
249
|
+
parse_scope_field(["read", "write"]) # "read write"
|
|
250
|
+
parse_scope_field(None) # "read" (default)
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
### CORS
|
|
254
|
+
|
|
255
|
+
```python
|
|
256
|
+
from mcp_auth_framework.cors import parse_allowed_origins, build_cors_headers
|
|
257
|
+
|
|
258
|
+
# Reads ALLOWED_MCP_ORIGINS env var (comma-separated)
|
|
259
|
+
origins = parse_allowed_origins()
|
|
260
|
+
|
|
261
|
+
# Returns CORS headers if request origin is in allowlist
|
|
262
|
+
headers = build_cors_headers(request, origins)
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
## Configuration
|
|
266
|
+
|
|
267
|
+
| Env Variable | Description | Default |
|
|
268
|
+
|-------------|-------------|---------|
|
|
269
|
+
| `DATABASE_URL` | PostgreSQL connection string (for `PostgresTokenStorage`) | Required for postgres |
|
|
270
|
+
| `ALLOWED_MCP_ORIGINS` | Comma-separated allowed CORS origins | Empty (no CORS) |
|
|
271
|
+
|
|
272
|
+
## License
|
|
273
|
+
|
|
274
|
+
MIT
|