mcp-core-auth 0.2.1__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_core/__init__.py +324 -0
- mcp_core/auth.py +235 -0
- mcp_core/billing.py +269 -0
- mcp_core/dcr.py +185 -0
- mcp_core/health.py +71 -0
- mcp_core/mcp_mount.py +240 -0
- mcp_core/py.typed +0 -0
- mcp_core/routes.py +251 -0
- mcp_core/tool_logging.py +68 -0
- mcp_core_auth-0.2.1.dist-info/METADATA +146 -0
- mcp_core_auth-0.2.1.dist-info/RECORD +13 -0
- mcp_core_auth-0.2.1.dist-info/WHEEL +4 -0
- mcp_core_auth-0.2.1.dist-info/licenses/LICENSE +21 -0
mcp_core/__init__.py
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"""
|
|
2
|
+
mcp-core: Auth, billing, and logging infrastructure for MCP-first servers.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
from mcp_core import MCPCore
|
|
6
|
+
|
|
7
|
+
core = MCPCore(
|
|
8
|
+
product_name="myapp",
|
|
9
|
+
logto_endpoint="https://your-tenant.logto.app", # or self-hosted: https://auth.example.com
|
|
10
|
+
logto_api_resource="https://api.example.com",
|
|
11
|
+
mongodb_uri="mongodb+srv://...",
|
|
12
|
+
db_name="myapp",
|
|
13
|
+
stripe_secret_key="sk_test_...",
|
|
14
|
+
stripe_price_id="price_...",
|
|
15
|
+
stripe_meter_event="tool_calls",
|
|
16
|
+
free_credits=25,
|
|
17
|
+
tool_costs={"browse": 0, "generate": 2},
|
|
18
|
+
read_only_tools={"browse"},
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
# In your tool handler:
|
|
22
|
+
user = await core.auth_and_bill(request, "narrate_text")
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import logging
|
|
26
|
+
import os
|
|
27
|
+
from typing import Any, Dict, List, Optional, Set
|
|
28
|
+
|
|
29
|
+
from fastapi import FastAPI, Request
|
|
30
|
+
|
|
31
|
+
from .auth import LogtoAuth
|
|
32
|
+
from .billing import StripeBilling
|
|
33
|
+
from .dcr import LogtoDCR
|
|
34
|
+
from .health import HealthCheck
|
|
35
|
+
from .mcp_mount import mount_mcp
|
|
36
|
+
from .routes import install_routes
|
|
37
|
+
from .tool_logging import ToolLogger
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"MCPCore", "LogtoAuth", "StripeBilling", "HealthCheck", "ToolLogger",
|
|
41
|
+
"LogtoDCR", "mount_mcp",
|
|
42
|
+
]
|
|
43
|
+
__version__ = "0.1.3"
|
|
44
|
+
|
|
45
|
+
logger = logging.getLogger(__name__)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class MCPCore:
|
|
49
|
+
"""Facade that wires auth, billing, logging, and health together.
|
|
50
|
+
|
|
51
|
+
All parameters can also be provided via environment variables
|
|
52
|
+
with MCP_CORE_ prefix (e.g. MCP_CORE_PRODUCT_NAME).
|
|
53
|
+
Constructor args take precedence over env vars.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
product_name: str = "",
|
|
59
|
+
# Logto auth
|
|
60
|
+
logto_endpoint: str = "",
|
|
61
|
+
logto_api_resource: str = "",
|
|
62
|
+
free_credits: int = 0,
|
|
63
|
+
dev_auth_bypass: bool = False,
|
|
64
|
+
dev_user_id: str = "local-dev-user",
|
|
65
|
+
reject_m2m: bool = True,
|
|
66
|
+
# MongoDB
|
|
67
|
+
mongodb_uri: str = "",
|
|
68
|
+
db_name: str = "",
|
|
69
|
+
# Stripe billing
|
|
70
|
+
stripe_secret_key: str = "",
|
|
71
|
+
stripe_price_id: str = "",
|
|
72
|
+
stripe_meter_event: str = "mcp_tool_calls",
|
|
73
|
+
stripe_webhook_secret: str = "",
|
|
74
|
+
billing_success_url: str = "",
|
|
75
|
+
billing_cancel_url: str = "",
|
|
76
|
+
# Tools
|
|
77
|
+
tool_costs: Optional[Dict[str, int]] = None,
|
|
78
|
+
read_only_tools: Optional[Set[str]] = None,
|
|
79
|
+
# MCP OAuth
|
|
80
|
+
mcp_logto_app_id: str = "",
|
|
81
|
+
mcp_logto_app_secret: str = "",
|
|
82
|
+
oauth_scopes: Optional[List[str]] = None,
|
|
83
|
+
# Logto Management API (enables real RFC 7591 DCR when provided)
|
|
84
|
+
logto_mgmt_app_id: str = "",
|
|
85
|
+
logto_mgmt_app_secret: str = "",
|
|
86
|
+
logto_mgmt_api_resource: str = "",
|
|
87
|
+
# Set this when Management tokens are issued on a different origin than
|
|
88
|
+
# `logto_endpoint` (Logto OSS self-host with admin on a separate port).
|
|
89
|
+
# Cloud deployments leave this empty.
|
|
90
|
+
logto_mgmt_token_endpoint: str = "",
|
|
91
|
+
# When set, /oauth/authorize bounces unauthenticated users here for
|
|
92
|
+
# product-branded inline sign-in instead of showing Logto's hosted
|
|
93
|
+
# page. The product is responsible for implementing this URL as a
|
|
94
|
+
# page that (1) accepts ?return_to=<url>, (2) validates it is
|
|
95
|
+
# same-origin, (3) runs the product's sign-in UI, (4) redirects
|
|
96
|
+
# the browser back to return_to on success. Logto's session cookie
|
|
97
|
+
# must be emitted with `Domain=.<apex>` so the retry carries it
|
|
98
|
+
# to /oauth/authorize. Leave empty to keep using Logto's hosted UI.
|
|
99
|
+
branded_sign_in_url: str = "",
|
|
100
|
+
):
|
|
101
|
+
def _env(key: str, default: str = "") -> str:
|
|
102
|
+
return os.getenv(f"MCP_CORE_{key}", default)
|
|
103
|
+
|
|
104
|
+
self.product_name = product_name or _env("PRODUCT_NAME", "mcp-server")
|
|
105
|
+
_read_only = read_only_tools or set()
|
|
106
|
+
_free = free_credits or int(_env("FREE_CREDITS", "30"))
|
|
107
|
+
|
|
108
|
+
# Auth
|
|
109
|
+
self.auth = LogtoAuth(
|
|
110
|
+
endpoint=logto_endpoint or _env("LOGTO_ENDPOINT"),
|
|
111
|
+
api_resource=logto_api_resource or _env("LOGTO_API_RESOURCE"),
|
|
112
|
+
free_credits=_free,
|
|
113
|
+
dev_bypass=dev_auth_bypass or _env("DEV_AUTH_BYPASS") == "1",
|
|
114
|
+
dev_user_id=dev_user_id,
|
|
115
|
+
read_only_tools=_read_only,
|
|
116
|
+
reject_m2m=reject_m2m,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
# Billing
|
|
120
|
+
self.billing = StripeBilling(
|
|
121
|
+
stripe_secret_key=stripe_secret_key or _env("STRIPE_SECRET_KEY"),
|
|
122
|
+
price_id=stripe_price_id or _env("STRIPE_PRICE_ID"),
|
|
123
|
+
meter_event=stripe_meter_event or _env("STRIPE_METER_EVENT", "mcp_tool_calls"),
|
|
124
|
+
free_credits=_free,
|
|
125
|
+
tool_costs=tool_costs or {},
|
|
126
|
+
read_only_tools=_read_only,
|
|
127
|
+
success_url=billing_success_url or _env("BILLING_SUCCESS_URL"),
|
|
128
|
+
cancel_url=billing_cancel_url or _env("BILLING_CANCEL_URL"),
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
# MongoDB
|
|
132
|
+
self._mongodb_uri = mongodb_uri or _env("MONGODB_URI")
|
|
133
|
+
self._db_name = db_name or _env("DB_NAME", self.product_name)
|
|
134
|
+
self._db: Any = None # set in connect() or injected directly
|
|
135
|
+
|
|
136
|
+
# Logging
|
|
137
|
+
self.tool_logger = ToolLogger(
|
|
138
|
+
db=None, # set after connect()
|
|
139
|
+
product_name=self.product_name,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
# Health
|
|
143
|
+
self.health = HealthCheck(product_name=self.product_name)
|
|
144
|
+
|
|
145
|
+
# MCP OAuth config
|
|
146
|
+
self._mcp_app_id = mcp_logto_app_id or _env("MCP_LOGTO_APP_ID")
|
|
147
|
+
self._mcp_app_secret = mcp_logto_app_secret or _env("MCP_LOGTO_APP_SECRET")
|
|
148
|
+
self._webhook_secret = stripe_webhook_secret or _env("STRIPE_WEBHOOK_SECRET")
|
|
149
|
+
self._oauth_scopes = oauth_scopes
|
|
150
|
+
self._branded_sign_in_url = (
|
|
151
|
+
branded_sign_in_url or _env("BRANDED_SIGN_IN_URL", "")
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
# Real DCR via Logto Management API (optional). If mgmt creds are
|
|
155
|
+
# provided, every /oauth/register call creates a fresh Logto app with
|
|
156
|
+
# the caller's redirect_uris baked in — fixing the dynamic-port
|
|
157
|
+
# loopback case that fake DCR can't handle.
|
|
158
|
+
_mgmt_id = logto_mgmt_app_id or _env("LOGTO_MGMT_APP_ID")
|
|
159
|
+
_mgmt_secret = logto_mgmt_app_secret or _env("LOGTO_MGMT_APP_SECRET")
|
|
160
|
+
self.dcr: Optional[LogtoDCR] = None
|
|
161
|
+
if _mgmt_id and _mgmt_secret and self.auth.endpoint:
|
|
162
|
+
self.dcr = LogtoDCR(
|
|
163
|
+
logto_endpoint=self.auth.endpoint,
|
|
164
|
+
mgmt_app_id=_mgmt_id,
|
|
165
|
+
mgmt_app_secret=_mgmt_secret,
|
|
166
|
+
mgmt_api_resource=(
|
|
167
|
+
logto_mgmt_api_resource
|
|
168
|
+
or _env("LOGTO_MGMT_API_RESOURCE", "")
|
|
169
|
+
),
|
|
170
|
+
mgmt_token_endpoint=(
|
|
171
|
+
logto_mgmt_token_endpoint
|
|
172
|
+
or _env("LOGTO_MGMT_TOKEN_ENDPOINT", "")
|
|
173
|
+
),
|
|
174
|
+
app_name_prefix=f"mcp-dcr-{self.product_name}",
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
# ── Database ──────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
@property
|
|
180
|
+
def db(self) -> Any:
|
|
181
|
+
return self._db
|
|
182
|
+
|
|
183
|
+
@db.setter
|
|
184
|
+
def db(self, value: Any) -> None:
|
|
185
|
+
self._db = value
|
|
186
|
+
self.tool_logger.db = value
|
|
187
|
+
|
|
188
|
+
async def connect_db(self) -> Any:
|
|
189
|
+
"""Connect to MongoDB using configured URI. Returns the database."""
|
|
190
|
+
if not self._mongodb_uri:
|
|
191
|
+
logger.warning("[mcp-core] No MONGODB_URI — running without DB")
|
|
192
|
+
return None
|
|
193
|
+
import motor.motor_asyncio
|
|
194
|
+
|
|
195
|
+
client = motor.motor_asyncio.AsyncIOMotorClient(self._mongodb_uri)
|
|
196
|
+
self.db = client[self._db_name]
|
|
197
|
+
logger.info("[mcp-core] Connected to MongoDB: %s", self._db_name)
|
|
198
|
+
return self.db
|
|
199
|
+
|
|
200
|
+
# ── Main middleware ─────���──────────────────────────────
|
|
201
|
+
|
|
202
|
+
async def auth_and_bill(
|
|
203
|
+
self, request: Request, tool_name: str
|
|
204
|
+
) -> Dict[str, Any]:
|
|
205
|
+
"""Combined auth + billing check. The main entry point for tool handlers.
|
|
206
|
+
|
|
207
|
+
Returns user dict. Raises HTTPException on auth/billing failure.
|
|
208
|
+
|
|
209
|
+
On paid-tool calls the post-deduction billing result is attached to
|
|
210
|
+
the returned user as ``user["_billing"]`` = ``{"cost", "source",
|
|
211
|
+
"remaining_credits"}``. Tool handlers that want to surface a usage
|
|
212
|
+
block in their response can read it directly; callers that don't
|
|
213
|
+
care keep working unchanged (non-breaking addition).
|
|
214
|
+
"""
|
|
215
|
+
user = await self.auth.require_auth(request, tool_name, self.db)
|
|
216
|
+
if user is None:
|
|
217
|
+
# Read-only tool, no auth provided
|
|
218
|
+
return {
|
|
219
|
+
"logto_user_id": "anonymous",
|
|
220
|
+
"free_credits": 0,
|
|
221
|
+
"credits_used": 0,
|
|
222
|
+
}
|
|
223
|
+
billing_info = await self.billing.check_and_deduct(
|
|
224
|
+
self.db, user, tool_name, request
|
|
225
|
+
)
|
|
226
|
+
if billing_info is not None:
|
|
227
|
+
user["_billing"] = billing_info
|
|
228
|
+
return user
|
|
229
|
+
|
|
230
|
+
# ── Logging shortcut ──────────────────────────────────
|
|
231
|
+
|
|
232
|
+
async def log_tool_call(
|
|
233
|
+
self,
|
|
234
|
+
request: Request,
|
|
235
|
+
tool: str,
|
|
236
|
+
user: Optional[Dict[str, Any]] = None,
|
|
237
|
+
duration_ms: int = 0,
|
|
238
|
+
status: str = "ok",
|
|
239
|
+
error: str = "",
|
|
240
|
+
meta: Optional[Dict[str, Any]] = None,
|
|
241
|
+
) -> None:
|
|
242
|
+
"""Log a tool call to the audit trail."""
|
|
243
|
+
user_id = (user or {}).get("logto_user_id", "")
|
|
244
|
+
cost = self.billing.get_tool_cost(tool)
|
|
245
|
+
await self.tool_logger.log(
|
|
246
|
+
request=request,
|
|
247
|
+
tool=tool,
|
|
248
|
+
user_id=user_id,
|
|
249
|
+
duration_ms=duration_ms,
|
|
250
|
+
status=status,
|
|
251
|
+
cost=cost,
|
|
252
|
+
error=error,
|
|
253
|
+
meta=meta,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
# ── FastAPI integration ───────────────────────────────
|
|
257
|
+
|
|
258
|
+
def install_routes(self, app: FastAPI) -> None:
|
|
259
|
+
"""Register standard routes: /health, /api/billing/credits, webhook, OAuth metadata."""
|
|
260
|
+
install_routes(app, self)
|
|
261
|
+
|
|
262
|
+
def mount_mcp(
|
|
263
|
+
self,
|
|
264
|
+
app: FastAPI,
|
|
265
|
+
*,
|
|
266
|
+
name: str,
|
|
267
|
+
description: str = "",
|
|
268
|
+
tags=("mcp",),
|
|
269
|
+
legacy_sse: bool = True,
|
|
270
|
+
mount_path_legacy: str = "/mcp",
|
|
271
|
+
mount_path_v2: str = "/mcp/v2",
|
|
272
|
+
instructions: str = "",
|
|
273
|
+
require_auth: bool = True,
|
|
274
|
+
) -> Dict[str, Any]:
|
|
275
|
+
"""Mount MCP transports (legacy SSE + stateless HTTP).
|
|
276
|
+
|
|
277
|
+
See `mcp_core.mcp_mount.mount_mcp` for full docs. This method
|
|
278
|
+
forwards `self` as `core` so callers don't repeat it.
|
|
279
|
+
"""
|
|
280
|
+
return mount_mcp(
|
|
281
|
+
app,
|
|
282
|
+
core=self,
|
|
283
|
+
name=name,
|
|
284
|
+
description=description,
|
|
285
|
+
tags=tags,
|
|
286
|
+
legacy_sse=legacy_sse,
|
|
287
|
+
mount_path_legacy=mount_path_legacy,
|
|
288
|
+
mount_path_v2=mount_path_v2,
|
|
289
|
+
instructions=instructions,
|
|
290
|
+
require_auth=require_auth,
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
def mcp_auth_config(self) -> Any:
|
|
294
|
+
"""Return an AuthConfig for fastapi-mcp.
|
|
295
|
+
|
|
296
|
+
Requires fastapi-mcp to be installed (it's a peer dependency).
|
|
297
|
+
"""
|
|
298
|
+
if not self.auth.endpoint or not self._mcp_app_id:
|
|
299
|
+
return None
|
|
300
|
+
try:
|
|
301
|
+
from fastapi_mcp.types import AuthConfig
|
|
302
|
+
except ImportError:
|
|
303
|
+
from fastapi_mcp import AuthConfig
|
|
304
|
+
return AuthConfig(
|
|
305
|
+
issuer=f"{self.auth.endpoint}/oidc",
|
|
306
|
+
oauth_metadata_url=(
|
|
307
|
+
f"{self.auth.endpoint}/oidc/.well-known/openid-configuration"
|
|
308
|
+
),
|
|
309
|
+
authorize_url=f"{self.auth.endpoint}/oidc/auth",
|
|
310
|
+
client_id=self._mcp_app_id,
|
|
311
|
+
client_secret=self._mcp_app_secret,
|
|
312
|
+
audience=self.auth.api_resource,
|
|
313
|
+
default_scope=" ".join(
|
|
314
|
+
self._oauth_scopes
|
|
315
|
+
or ["openid", "profile", "email"]
|
|
316
|
+
),
|
|
317
|
+
setup_proxies=True,
|
|
318
|
+
# Keep fake DCR on so fastapi-mcp advertises `registration_endpoint`
|
|
319
|
+
# in the auth-server metadata (MCP SDK refuses servers without it).
|
|
320
|
+
# When real DCR is enabled, mcp-core's /oauth/register route is
|
|
321
|
+
# registered first via install_routes(), so FastAPI's first-match
|
|
322
|
+
# router dispatches there and fastapi-mcp's fake handler never runs.
|
|
323
|
+
setup_fake_dynamic_registration=True,
|
|
324
|
+
)
|
mcp_core/auth.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Logto JWT validation and user provisioning for MCP-first servers.
|
|
3
|
+
|
|
4
|
+
Validates JWTs issued by Logto using JWKS endpoint.
|
|
5
|
+
Creates user records in MongoDB on first auth (race-condition-safe upsert).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import time
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from typing import Any, Dict, Optional, Set
|
|
12
|
+
|
|
13
|
+
import jwt
|
|
14
|
+
from fastapi import HTTPException, Request
|
|
15
|
+
from jwt import PyJWKClient
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
__all__ = ["LogtoAuth"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class LogtoAuth:
|
|
23
|
+
"""Logto JWT validation and user provisioning.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
endpoint: Logto tenant URL (e.g. "https://your-tenant.logto.app" or self-hosted "https://auth.example.com").
|
|
27
|
+
api_resource: Logto API resource / audience (e.g. "https://api.example.com").
|
|
28
|
+
free_credits: Credits granted to new users on first auth.
|
|
29
|
+
dev_bypass: Accept "Bearer dev-bypass" as a valid token (local dev only).
|
|
30
|
+
dev_user_id: User ID returned for dev-bypass tokens.
|
|
31
|
+
read_only_tools: Tool names that don't require authentication.
|
|
32
|
+
reject_m2m: Reject machine-to-machine tokens (sub == client_id) for paid tools.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
endpoint: str = "",
|
|
38
|
+
api_resource: str = "",
|
|
39
|
+
free_credits: int = 30,
|
|
40
|
+
dev_bypass: bool = False,
|
|
41
|
+
dev_user_id: str = "local-dev-user",
|
|
42
|
+
read_only_tools: Optional[Set[str]] = None,
|
|
43
|
+
reject_m2m: bool = True,
|
|
44
|
+
):
|
|
45
|
+
self.endpoint = endpoint.rstrip("/") if endpoint else ""
|
|
46
|
+
self.api_resource = api_resource
|
|
47
|
+
self.free_credits = free_credits
|
|
48
|
+
self.dev_bypass = dev_bypass
|
|
49
|
+
self.dev_user_id = dev_user_id
|
|
50
|
+
self.read_only_tools = read_only_tools or set()
|
|
51
|
+
self.reject_m2m = reject_m2m
|
|
52
|
+
|
|
53
|
+
self._jwks_client: Optional[PyJWKClient] = None
|
|
54
|
+
self._jwks_last_init: float = 0.0
|
|
55
|
+
|
|
56
|
+
# ── JWKS ──────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
def _get_jwks_client(self) -> Optional[PyJWKClient]:
|
|
59
|
+
if not self.endpoint:
|
|
60
|
+
return None
|
|
61
|
+
# Refresh JWKS client every hour
|
|
62
|
+
if self._jwks_client and (time.time() - self._jwks_last_init) < 3600:
|
|
63
|
+
return self._jwks_client
|
|
64
|
+
jwks_url = f"{self.endpoint}/oidc/jwks"
|
|
65
|
+
try:
|
|
66
|
+
self._jwks_client = PyJWKClient(jwks_url, cache_keys=True)
|
|
67
|
+
self._jwks_last_init = time.time()
|
|
68
|
+
logger.info("[auth] JWKS client initialized: %s", jwks_url)
|
|
69
|
+
return self._jwks_client
|
|
70
|
+
except Exception as e:
|
|
71
|
+
logger.error("[auth] Failed to initialize JWKS client: %s", e)
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
# ── Token extraction ──────────────────────────────────
|
|
75
|
+
|
|
76
|
+
@staticmethod
|
|
77
|
+
def _extract_bearer_token(request: Request) -> Optional[str]:
|
|
78
|
+
auth = request.headers.get("authorization", "")
|
|
79
|
+
if auth.startswith("Bearer "):
|
|
80
|
+
return auth[7:]
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
# ── Token validation ──────────────────────────────────
|
|
84
|
+
|
|
85
|
+
async def verify_token(self, request: Request) -> Optional[Dict[str, Any]]:
|
|
86
|
+
"""Validate JWT from request Authorization header.
|
|
87
|
+
|
|
88
|
+
Returns decoded payload if valid, None if no token provided.
|
|
89
|
+
Raises HTTPException(401) if token is invalid/expired.
|
|
90
|
+
"""
|
|
91
|
+
token = self._extract_bearer_token(request)
|
|
92
|
+
if not token:
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
# Dev bypass
|
|
96
|
+
if self.dev_bypass and token == "dev-bypass":
|
|
97
|
+
return {"sub": self.dev_user_id, "email": "dev@localhost"}
|
|
98
|
+
|
|
99
|
+
jwks_client = self._get_jwks_client()
|
|
100
|
+
if not jwks_client:
|
|
101
|
+
logger.warning("[auth] Auth not configured, allowing request through")
|
|
102
|
+
return {"sub": "anonymous", "dev_mode": True}
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
signing_key = jwks_client.get_signing_key_from_jwt(token)
|
|
106
|
+
payload = jwt.decode(
|
|
107
|
+
token,
|
|
108
|
+
signing_key.key,
|
|
109
|
+
algorithms=["RS256", "ES256", "ES384", "ES512"],
|
|
110
|
+
audience=self.api_resource,
|
|
111
|
+
issuer=f"{self.endpoint}/oidc",
|
|
112
|
+
options={"verify_exp": True},
|
|
113
|
+
leeway=30,
|
|
114
|
+
)
|
|
115
|
+
return payload
|
|
116
|
+
except jwt.ExpiredSignatureError:
|
|
117
|
+
raise HTTPException(status_code=401, detail="Token expired")
|
|
118
|
+
except (jwt.InvalidTokenError, jwt.exceptions.PyJWKClientError) as e:
|
|
119
|
+
raise HTTPException(status_code=401, detail=f"Invalid token: {e}")
|
|
120
|
+
|
|
121
|
+
# ── User provisioning ─────────────────────────────────
|
|
122
|
+
|
|
123
|
+
async def get_or_create_user(
|
|
124
|
+
self, db: Any, token_payload: Dict[str, Any]
|
|
125
|
+
) -> Dict[str, Any]:
|
|
126
|
+
"""Get or create user record in MongoDB from token payload.
|
|
127
|
+
|
|
128
|
+
Uses find_one_and_update with upsert to avoid race conditions.
|
|
129
|
+
Rejects M2M tokens if reject_m2m is True.
|
|
130
|
+
"""
|
|
131
|
+
sub = token_payload.get("sub", "")
|
|
132
|
+
client_id = token_payload.get("client_id", "")
|
|
133
|
+
|
|
134
|
+
# Reject M2M tokens (sub == client_id means it's an app, not a user)
|
|
135
|
+
if self.reject_m2m and sub and client_id and sub == client_id:
|
|
136
|
+
raise HTTPException(
|
|
137
|
+
status_code=403,
|
|
138
|
+
detail="Machine-to-machine tokens cannot call paid tools. "
|
|
139
|
+
"Use a per-user OAuth token.",
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
if db is None:
|
|
143
|
+
return self._ephemeral_user(token_payload)
|
|
144
|
+
|
|
145
|
+
if not sub:
|
|
146
|
+
raise HTTPException(status_code=401, detail="Token missing 'sub' claim")
|
|
147
|
+
|
|
148
|
+
result = await db["users"].find_one_and_update(
|
|
149
|
+
{"logto_user_id": sub},
|
|
150
|
+
{
|
|
151
|
+
"$setOnInsert": {
|
|
152
|
+
"logto_user_id": sub,
|
|
153
|
+
"email": token_payload.get("email", ""),
|
|
154
|
+
"free_credits": self.free_credits,
|
|
155
|
+
"credits_used": 0,
|
|
156
|
+
"stripe_customer_id": None,
|
|
157
|
+
"stripe_subscription_id": None,
|
|
158
|
+
"created_at": datetime.now(timezone.utc),
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
upsert=True,
|
|
162
|
+
return_document=True, # motor uses True, not ReturnDocument enum
|
|
163
|
+
)
|
|
164
|
+
return result
|
|
165
|
+
|
|
166
|
+
def _ephemeral_user(self, token_payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
167
|
+
"""Return an in-memory user dict when no DB is available."""
|
|
168
|
+
return {
|
|
169
|
+
"logto_user_id": token_payload.get("sub", "anonymous"),
|
|
170
|
+
"email": token_payload.get("email", ""),
|
|
171
|
+
"free_credits": self.free_credits,
|
|
172
|
+
"credits_used": 0,
|
|
173
|
+
"stripe_customer_id": None,
|
|
174
|
+
"stripe_subscription_id": None,
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
# ── Require auth for tool ─────────────────────────────
|
|
178
|
+
|
|
179
|
+
async def require_auth(
|
|
180
|
+
self, request: Request, tool_name: str, db: Any = None
|
|
181
|
+
) -> Optional[Dict[str, Any]]:
|
|
182
|
+
"""Validate auth for a tool call.
|
|
183
|
+
|
|
184
|
+
Returns user dict for paid tools, None for read-only tools without a token.
|
|
185
|
+
Raises HTTPException(401) if a paid tool is called without valid auth.
|
|
186
|
+
"""
|
|
187
|
+
if tool_name in self.read_only_tools:
|
|
188
|
+
payload = await self.verify_token(request)
|
|
189
|
+
# pymongo Database raises NotImplementedError on bool() (historical
|
|
190
|
+
# truthiness gotcha). The paid-tool branch below uses the correct
|
|
191
|
+
# `db is not None` — keep read-only consistent so authenticated
|
|
192
|
+
# read-only calls don't 500 on the first call.
|
|
193
|
+
if payload and db is not None:
|
|
194
|
+
return await self.get_or_create_user(db, payload)
|
|
195
|
+
return None
|
|
196
|
+
|
|
197
|
+
payload = await self.verify_token(request)
|
|
198
|
+
if payload is None:
|
|
199
|
+
raise HTTPException(
|
|
200
|
+
status_code=401,
|
|
201
|
+
detail=f"Authentication required for {tool_name}. "
|
|
202
|
+
"Provide a valid Bearer token.",
|
|
203
|
+
)
|
|
204
|
+
if db is not None:
|
|
205
|
+
return await self.get_or_create_user(db, payload)
|
|
206
|
+
return self._ephemeral_user(payload)
|
|
207
|
+
|
|
208
|
+
# ── OAuth metadata ────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
def oauth_protected_resource_metadata(
|
|
211
|
+
self, scopes: Optional[list] = None, base_url: Optional[str] = None
|
|
212
|
+
) -> Dict[str, Any]:
|
|
213
|
+
"""RFC 9728 /.well-known/oauth-protected-resource response.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
scopes: Supported OAuth scopes.
|
|
217
|
+
base_url: When set (typically from the request), use the server's
|
|
218
|
+
own URL as the authorization server. This is required when
|
|
219
|
+
fastapi-mcp's ``setup_proxies=True`` proxies OAuth routes
|
|
220
|
+
through the server itself.
|
|
221
|
+
"""
|
|
222
|
+
if base_url:
|
|
223
|
+
auth_servers = [base_url.rstrip("/")]
|
|
224
|
+
elif self.endpoint:
|
|
225
|
+
auth_servers = [f"{self.endpoint}/oidc"]
|
|
226
|
+
else:
|
|
227
|
+
auth_servers = []
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
"resource": self.api_resource,
|
|
231
|
+
"authorization_servers": auth_servers,
|
|
232
|
+
"scopes_supported": scopes
|
|
233
|
+
or ["openid", "profile", "email"],
|
|
234
|
+
"bearer_methods_supported": ["header"],
|
|
235
|
+
}
|