lambda-erp 1.1.2__tar.gz → 1.1.3__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.
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/PKG-INFO +1 -1
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/api/auth.py +15 -5
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/api/main.py +3 -0
- lambda_erp-1.1.3/api/mcp_oauth.py +423 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/api/oauth.py +5 -1
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/api/routers/mcp.py +58 -9
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/frontend/src/api/client.ts +9 -5
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/lambda_erp/database.py +24 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/pyproject.toml +1 -1
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/.gitignore +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/LICENSE +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/README.md +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/api/__init__.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/api/attachments.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/api/bootstrap.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/api/chat.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/api/chat_links.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/api/demo_limits.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/api/deps.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/api/errors.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/api/list_values.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/api/pdf.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/api/pdf_contract.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/api/pdf_exports.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/api/pdf_profiles.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/api/providers.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/api/remarks_md.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/api/routers/__init__.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/api/routers/accounting.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/api/routers/actions.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/api/routers/admin.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/api/routers/analytics.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/api/routers/availability.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/api/routers/bank_reconciliation.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/api/routers/bank_statements.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/api/routers/chat_api.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/api/routers/documents.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/api/routers/masters.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/api/routers/proposals.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/api/routers/reports.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/api/routers/setup.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/api/services.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/api/templates/_proposal_totals.html +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/api/templates/_record_base.html +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/api/templates/document.html +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/api/templates/proposal.html +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/api/templates/record.html +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/api/time_filters.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/api/tool_permissions.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/docs/agents/README.md +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/frontend/LICENSE +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/frontend/README.md +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/lambda_erp/__init__.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/lambda_erp/accounting/__init__.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/lambda_erp/accounting/bank_account.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/lambda_erp/accounting/bank_reconciliation.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/lambda_erp/accounting/bank_statement_import.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/lambda_erp/accounting/bank_transaction.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/lambda_erp/accounting/budget.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/lambda_erp/accounting/camt.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/lambda_erp/accounting/chart_of_accounts.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/lambda_erp/accounting/general_ledger.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/lambda_erp/accounting/journal_entry.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/lambda_erp/accounting/payment_entry.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/lambda_erp/accounting/pos_invoice.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/lambda_erp/accounting/purchase_invoice.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/lambda_erp/accounting/revaluation.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/lambda_erp/accounting/sales_invoice.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/lambda_erp/accounting/settlement.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/lambda_erp/accounting/setup/__init__.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/lambda_erp/accounting/setup/engine.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/lambda_erp/accounting/setup/pack.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/lambda_erp/accounting/setup/packs/ch.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/lambda_erp/accounting/setup/packs/generic.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/lambda_erp/accounting/setup/profiles.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/lambda_erp/accounting/setup/spine.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/lambda_erp/accounting/subscription.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/lambda_erp/assets/__init__.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/lambda_erp/assets/asset.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/lambda_erp/assets/lifecycle.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/lambda_erp/assets/reservation.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/lambda_erp/buying/__init__.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/lambda_erp/buying/purchase_order.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/lambda_erp/controllers/__init__.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/lambda_erp/controllers/currency.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/lambda_erp/controllers/defaults.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/lambda_erp/controllers/item_price.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/lambda_erp/controllers/item_prices.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/lambda_erp/controllers/pricing_rule.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/lambda_erp/controllers/taxes_and_totals.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/lambda_erp/exceptions.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/lambda_erp/hooks.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/lambda_erp/master_integrity.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/lambda_erp/model.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/lambda_erp/selling/__init__.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/lambda_erp/selling/proposal.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/lambda_erp/selling/quotation.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/lambda_erp/selling/sales_order.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/lambda_erp/simulation.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/lambda_erp/stock/__init__.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/lambda_erp/stock/delivery_note.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/lambda_erp/stock/purchase_receipt.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/lambda_erp/stock/stock_entry.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/lambda_erp/stock/stock_ledger.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/lambda_erp/timestamps.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/lambda_erp/utils.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/lambda_erp/validation.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/lambda_erp/workflow.py +0 -0
- {lambda_erp-1.1.2 → lambda_erp-1.1.3}/terraform/README.md +0 -0
|
@@ -9,6 +9,7 @@ import uuid
|
|
|
9
9
|
import secrets
|
|
10
10
|
import hashlib
|
|
11
11
|
from datetime import datetime, timedelta, timezone
|
|
12
|
+
from typing import Literal
|
|
12
13
|
|
|
13
14
|
import bcrypt
|
|
14
15
|
from jose import jwt, JWTError
|
|
@@ -259,12 +260,18 @@ def refresh_auth_principal(principal: dict | None) -> dict | None:
|
|
|
259
260
|
else owner["role"]
|
|
260
261
|
)
|
|
261
262
|
owner["role"] = effective_role
|
|
262
|
-
|
|
263
|
+
scope_role = principal.get("oauth_scope_role")
|
|
264
|
+
if scope_role:
|
|
265
|
+
owner["role"] = min((owner["role"], scope_role), key=_role_rank)
|
|
266
|
+
refreshed = make_auth_principal(
|
|
263
267
|
owner,
|
|
264
268
|
API_KEY_CREDENTIAL,
|
|
265
269
|
name=principal.get("name") or key.get("session_owner") or owner["name"],
|
|
266
270
|
api_key_id=key["id"],
|
|
267
271
|
)
|
|
272
|
+
if scope_role:
|
|
273
|
+
refreshed["oauth_scope_role"] = scope_role
|
|
274
|
+
return refreshed
|
|
268
275
|
|
|
269
276
|
user_id = principal.get("user_id") or principal.get("name")
|
|
270
277
|
user = db.get_value(
|
|
@@ -981,6 +988,7 @@ class ApiKeyCreate(BaseModel):
|
|
|
981
988
|
# Optional role CAP. Defaults to the creator's own role; may only be equal
|
|
982
989
|
# or lower — a key can never out-rank its owner.
|
|
983
990
|
role: str | None = None
|
|
991
|
+
app_type: Literal['claude_code', 'codex', 'other'] = 'other'
|
|
984
992
|
|
|
985
993
|
|
|
986
994
|
def _serialize_api_key(row: dict) -> dict:
|
|
@@ -997,6 +1005,7 @@ def _serialize_api_key(row: dict) -> dict:
|
|
|
997
1005
|
"created_at": row.get("created_at"),
|
|
998
1006
|
"last_used_at": row.get("last_used_at"),
|
|
999
1007
|
"revoked": bool(row.get("revoked")),
|
|
1008
|
+
"app_type": row.get("app_type") or "other",
|
|
1000
1009
|
}
|
|
1001
1010
|
|
|
1002
1011
|
|
|
@@ -1024,7 +1033,7 @@ def list_api_keys(user: dict = Depends(require_interactive_user)):
|
|
|
1024
1033
|
db = get_db()
|
|
1025
1034
|
base = (
|
|
1026
1035
|
'SELECT k.id, k.name, k.owner, k.role, k.key_prefix, k.created_at, '
|
|
1027
|
-
'k.last_used_at, k.revoked, u.full_name AS owner_full_name, u.email AS owner_email '
|
|
1036
|
+
'k.last_used_at, k.revoked, k.app_type, u.full_name AS owner_full_name, u.email AS owner_email '
|
|
1028
1037
|
'FROM "Api Key" k LEFT JOIN "User" u ON u.name = k.owner '
|
|
1029
1038
|
)
|
|
1030
1039
|
if user["role"] == "admin":
|
|
@@ -1068,15 +1077,16 @@ def create_api_key(data: ApiKeyCreate, user: dict = Depends(require_interactive_
|
|
|
1068
1077
|
created_at = now()
|
|
1069
1078
|
db.sql(
|
|
1070
1079
|
'INSERT INTO "Api Key" '
|
|
1071
|
-
'(id, name, owner, key_hash, key_prefix, role, session_owner, created_at, last_used_at, revoked) '
|
|
1072
|
-
'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0)',
|
|
1073
|
-
[key_id, name, user["name"], key_hash, key_prefix, role, f"api:{user['name']}", created_at, None],
|
|
1080
|
+
'(id, name, owner, key_hash, key_prefix, role, session_owner, created_at, last_used_at, revoked, app_type) '
|
|
1081
|
+
'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?)',
|
|
1082
|
+
[key_id, name, user["name"], key_hash, key_prefix, role, f"api:{user['name']}", created_at, None, data.app_type],
|
|
1074
1083
|
)
|
|
1075
1084
|
db.conn.commit()
|
|
1076
1085
|
row = {
|
|
1077
1086
|
"id": key_id, "name": name, "owner": user["name"], "role": role, "key_prefix": key_prefix,
|
|
1078
1087
|
"created_at": created_at, "last_used_at": None, "revoked": 0,
|
|
1079
1088
|
"owner_full_name": user.get("full_name"), "owner_email": user.get("email"),
|
|
1089
|
+
"app_type": data.app_type,
|
|
1080
1090
|
}
|
|
1081
1091
|
result = _serialize_api_key(row)
|
|
1082
1092
|
result["is_mine"] = True
|
|
@@ -17,6 +17,7 @@ from lambda_erp.database import setup
|
|
|
17
17
|
from api.errors import register_exception_handlers
|
|
18
18
|
from api.auth import router as auth_router, COOKIE_NAME, decode_token
|
|
19
19
|
from api.oauth import router as oauth_router
|
|
20
|
+
from api.mcp_oauth import router as mcp_oauth_router, OAuthError, oauth_error_handler
|
|
20
21
|
from api.attachments import router as attachments_router
|
|
21
22
|
from api.chat import chat_websocket, router as chat_router
|
|
22
23
|
from api.routers import admin, documents, masters, reports, setup as setup_router, bank_reconciliation, bank_statements, analytics, accounting, proposals, chat_api, mcp, availability, actions
|
|
@@ -101,10 +102,12 @@ app.add_middleware(
|
|
|
101
102
|
|
|
102
103
|
# Exception handlers
|
|
103
104
|
register_exception_handlers(app)
|
|
105
|
+
app.add_exception_handler(OAuthError, oauth_error_handler)
|
|
104
106
|
|
|
105
107
|
# Routers
|
|
106
108
|
app.include_router(auth_router, prefix="/api")
|
|
107
109
|
app.include_router(oauth_router, prefix="/api")
|
|
110
|
+
app.include_router(mcp_oauth_router)
|
|
108
111
|
app.include_router(attachments_router, prefix="/api")
|
|
109
112
|
app.include_router(documents.router, prefix="/api")
|
|
110
113
|
app.include_router(proposals.router, prefix="/api")
|
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
"""OAuth authorization-code + S256 PKCE for remote MCP connectors.
|
|
2
|
+
|
|
3
|
+
Dynamic registration supports public and confidential clients; no client URLs
|
|
4
|
+
are fetched. Grants reuse the per-user Api Key role/revocation model, but have
|
|
5
|
+
no API-key secret. Opaque, hashed OAuth tokens are valid only at this MCP resource.
|
|
6
|
+
"""
|
|
7
|
+
import base64
|
|
8
|
+
import hashlib
|
|
9
|
+
import hmac
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import secrets
|
|
14
|
+
import time
|
|
15
|
+
import uuid
|
|
16
|
+
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit, unquote
|
|
17
|
+
|
|
18
|
+
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
19
|
+
from fastapi.responses import JSONResponse, RedirectResponse
|
|
20
|
+
from jose import jwt, JWTError
|
|
21
|
+
from pydantic import BaseModel
|
|
22
|
+
|
|
23
|
+
from api.auth import (ALGORITHM, SECRET_KEY, API_KEY_CREDENTIAL, ROLE_HIERARCHY,
|
|
24
|
+
_setting_enabled, require_interactive_user,
|
|
25
|
+
refresh_auth_principal)
|
|
26
|
+
from lambda_erp.database import get_db
|
|
27
|
+
from lambda_erp.utils import now
|
|
28
|
+
|
|
29
|
+
router = APIRouter(tags=['mcp-oauth'])
|
|
30
|
+
SCOPES = {'erp:read': 'viewer', 'erp:write': 'manager', 'erp:admin': 'admin'}
|
|
31
|
+
ACCESS_TTL = 3600
|
|
32
|
+
REFRESH_TTL = 30 * 86400
|
|
33
|
+
RETURN_COOKIE = 'lambda_erp_mcp_return'
|
|
34
|
+
NO_STORE = {'Cache-Control': 'no-store', 'Pragma': 'no-cache'}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class OAuthError(Exception):
|
|
38
|
+
def __init__(self, error, description, status=400):
|
|
39
|
+
self.error, self.description, self.status = error, description, status
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
async def oauth_error_handler(request, exc):
|
|
43
|
+
return JSONResponse({'error': exc.error, 'error_description': exc.description},
|
|
44
|
+
status_code=exc.status, headers=NO_STORE)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def digest(value):
|
|
48
|
+
return hashlib.sha256(value.encode()).hexdigest()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def issuer(request):
|
|
52
|
+
# Set on reverse-proxied deployments if the external origin differs from
|
|
53
|
+
# Request.base_url. Never derive OAuth metadata from forwarded host headers.
|
|
54
|
+
return os.environ.get('MCP_PUBLIC_URL', str(request.base_url)).rstrip('/')
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def resource_url(request):
|
|
58
|
+
return issuer(request) + '/api/mcp'
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def challenge_header(request):
|
|
62
|
+
return {'WWW-Authenticate': f'Bearer resource_metadata="{issuer(request)}/.well-known/oauth-protected-resource/api/mcp"'}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _enabled():
|
|
66
|
+
if not _setting_enabled(get_db(), 'rest_api_enabled'):
|
|
67
|
+
raise OAuthError('access_denied', 'An administrator must enable REST API / MCP access first.', 403)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _client(client_id):
|
|
71
|
+
rows = get_db().sql('SELECT * FROM "MCP OAuth Client" WHERE id = ?', [client_id])
|
|
72
|
+
if not rows:
|
|
73
|
+
raise OAuthError('invalid_client', 'Unknown client. Add the connector again.', 401)
|
|
74
|
+
return rows[0]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _validate_redirect(uri):
|
|
78
|
+
if not isinstance(uri, str) or len(uri) > 2048 or re.search(r'[\s\\*]', uri):
|
|
79
|
+
raise OAuthError('invalid_redirect_uri', 'Invalid redirect URI.')
|
|
80
|
+
try:
|
|
81
|
+
parts = urlsplit(uri)
|
|
82
|
+
parts.port # Validate the port as well as the host.
|
|
83
|
+
except ValueError:
|
|
84
|
+
raise OAuthError('invalid_redirect_uri', 'Invalid redirect URI.')
|
|
85
|
+
if (not parts.hostname or parts.username or parts.password or parts.fragment
|
|
86
|
+
or parts.scheme not in {'https', 'http'}
|
|
87
|
+
or (parts.scheme == 'http' and parts.hostname not in {'127.0.0.1', '[::1]', '::1', 'localhost'})):
|
|
88
|
+
raise OAuthError('invalid_redirect_uri', 'Use HTTPS, or HTTP on a loopback address.')
|
|
89
|
+
return parts
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _redirect_matches(uri, registered):
|
|
93
|
+
candidate = _validate_redirect(uri)
|
|
94
|
+
for allowed in registered:
|
|
95
|
+
if uri == allowed:
|
|
96
|
+
return True
|
|
97
|
+
parts = urlsplit(allowed)
|
|
98
|
+
# RFC 8252 native apps choose an ephemeral loopback listener port.
|
|
99
|
+
if (parts.scheme == candidate.scheme == 'http' and parts.hostname == candidate.hostname
|
|
100
|
+
and parts.hostname in {'127.0.0.1', '::1', 'localhost'}
|
|
101
|
+
and parts.path == candidate.path and parts.query == candidate.query):
|
|
102
|
+
return True
|
|
103
|
+
return False
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _scope(value):
|
|
107
|
+
scopes = set((value or 'erp:read').split())
|
|
108
|
+
if not scopes or not scopes <= SCOPES.keys():
|
|
109
|
+
raise OAuthError('invalid_scope', 'Supported permissions: erp:read erp:write erp:admin.')
|
|
110
|
+
return ' '.join(sorted(scopes))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _role(scope):
|
|
114
|
+
return max((SCOPES[s] for s in scope.split()), key=lambda r: ROLE_HIERARCHY[r])
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@router.get('/.well-known/oauth-protected-resource')
|
|
118
|
+
@router.get('/.well-known/oauth-protected-resource/api/mcp')
|
|
119
|
+
def protected_resource(request: Request):
|
|
120
|
+
return JSONResponse({'resource': resource_url(request), 'resource_name': 'Lambda ERP',
|
|
121
|
+
'authorization_servers': [issuer(request)], 'scopes_supported': list(SCOPES),
|
|
122
|
+
'bearer_methods_supported': ['header']}, headers=NO_STORE)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@router.get('/.well-known/oauth-authorization-server')
|
|
126
|
+
def authorization_server(request: Request):
|
|
127
|
+
base = issuer(request) + '/api/mcp-oauth'
|
|
128
|
+
return JSONResponse({'issuer': issuer(request), 'authorization_endpoint': base + '/authorize',
|
|
129
|
+
'token_endpoint': base + '/token', 'registration_endpoint': base + '/register',
|
|
130
|
+
'revocation_endpoint': base + '/revoke', 'scopes_supported': list(SCOPES),
|
|
131
|
+
'response_types_supported': ['code'],
|
|
132
|
+
'grant_types_supported': ['authorization_code', 'refresh_token'],
|
|
133
|
+
'code_challenge_methods_supported': ['S256'],
|
|
134
|
+
'token_endpoint_auth_methods_supported': ['none', 'client_secret_post', 'client_secret_basic']},
|
|
135
|
+
headers=NO_STORE)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@router.get('/api/mcp-oauth/status')
|
|
139
|
+
def connection_status(request: Request, user: dict = Depends(require_interactive_user)):
|
|
140
|
+
db = get_db()
|
|
141
|
+
return {'mcp_url': resource_url(request), 'rest_enabled': _setting_enabled(db, 'rest_api_enabled'),
|
|
142
|
+
'chat_enabled': _setting_enabled(db, 'chat_api_enabled')}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@router.post('/api/mcp-oauth/register')
|
|
146
|
+
async def register(request: Request):
|
|
147
|
+
_enabled()
|
|
148
|
+
raw = await request.body()
|
|
149
|
+
if len(raw) > 16384:
|
|
150
|
+
raise OAuthError('invalid_client_metadata', 'Registration is too large.')
|
|
151
|
+
try:
|
|
152
|
+
data = json.loads(raw)
|
|
153
|
+
except ValueError:
|
|
154
|
+
raise OAuthError('invalid_client_metadata', 'Expected JSON.')
|
|
155
|
+
if not isinstance(data, dict):
|
|
156
|
+
raise OAuthError('invalid_client_metadata', 'Expected an object.')
|
|
157
|
+
uris = data.get('redirect_uris')
|
|
158
|
+
if not isinstance(uris, list) or not 1 <= len(uris) <= 10:
|
|
159
|
+
raise OAuthError('invalid_redirect_uri', 'Supply 1–10 redirect URIs.')
|
|
160
|
+
for uri in uris:
|
|
161
|
+
_validate_redirect(uri)
|
|
162
|
+
method = data.get('token_endpoint_auth_method', 'client_secret_basic')
|
|
163
|
+
if not isinstance(method, str) or method not in {'none', 'client_secret_post', 'client_secret_basic'}:
|
|
164
|
+
raise OAuthError('invalid_client_metadata', 'Unsupported client authentication method.')
|
|
165
|
+
if (data.get('response_types', ['code']) != ['code']
|
|
166
|
+
or not isinstance(data.get('grant_types', []), list)
|
|
167
|
+
or any(not isinstance(g, str) for g in data.get('grant_types', []))
|
|
168
|
+
or not set(data.get('grant_types', ['authorization_code'])) <= {'authorization_code', 'refresh_token'}):
|
|
169
|
+
raise OAuthError('invalid_client_metadata', 'Only authorization code and refresh grants are supported.')
|
|
170
|
+
name = data.get('client_name') or 'MCP connector'
|
|
171
|
+
if not isinstance(name, str) or len(name) > 100:
|
|
172
|
+
raise OAuthError('invalid_client_metadata', 'Client name must be at most 100 characters.')
|
|
173
|
+
db = get_db()
|
|
174
|
+
timestamp = int(time.time())
|
|
175
|
+
# Bound unauthenticated registration growth without relying on process-local
|
|
176
|
+
# counters (the same service can run on several replicas).
|
|
177
|
+
if db.sql('SELECT COUNT(*) AS n FROM "MCP OAuth Client" WHERE created_at > ?', [timestamp - 3600])[0]['n'] >= 1000:
|
|
178
|
+
raise OAuthError('temporarily_unavailable', 'Registration limit reached. Try again later.', 429)
|
|
179
|
+
client_id, secret = secrets.token_urlsafe(24), secrets.token_urlsafe(32)
|
|
180
|
+
with db.atomic():
|
|
181
|
+
db.sql('INSERT INTO "MCP OAuth Client" (id,name,redirect_uris,auth_method,secret_hash,created_at) VALUES (?,?,?,?,?,?)',
|
|
182
|
+
[client_id, name, json.dumps(uris), method, digest(secret) if method != 'none' else None, timestamp])
|
|
183
|
+
result = {'client_id': client_id, 'client_id_issued_at': timestamp, 'client_name': name,
|
|
184
|
+
'redirect_uris': uris, 'token_endpoint_auth_method': method,
|
|
185
|
+
'grant_types': ['authorization_code', 'refresh_token'], 'response_types': ['code']}
|
|
186
|
+
if method != 'none':
|
|
187
|
+
result.update(client_secret=secret, client_secret_expires_at=0)
|
|
188
|
+
return JSONResponse(result, status_code=201, headers=NO_STORE)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@router.get('/api/mcp-oauth/authorize')
|
|
192
|
+
def authorize(request: Request):
|
|
193
|
+
_enabled()
|
|
194
|
+
p = dict(request.query_params)
|
|
195
|
+
if any(len(request.query_params.getlist(k)) != 1 for k in p) or len(str(request.url)) > 10000:
|
|
196
|
+
raise OAuthError('invalid_request', 'Invalid authorization parameters.')
|
|
197
|
+
client = _client(p.get('client_id'))
|
|
198
|
+
if not _redirect_matches(p.get('redirect_uri'), json.loads(client['redirect_uris'])):
|
|
199
|
+
raise OAuthError('invalid_request', 'Redirect URI does not match the registered client.')
|
|
200
|
+
if p.get('response_type') != 'code':
|
|
201
|
+
raise OAuthError('unsupported_response_type', 'Use response_type=code.')
|
|
202
|
+
if p.get('code_challenge_method') != 'S256' or not re.fullmatch(r'[A-Za-z0-9_-]{43}', p.get('code_challenge', '')):
|
|
203
|
+
raise OAuthError('invalid_request', 'S256 PKCE is required.')
|
|
204
|
+
if p.get('resource', resource_url(request)) != resource_url(request):
|
|
205
|
+
raise OAuthError('invalid_target', 'Unknown MCP resource.')
|
|
206
|
+
p['resource'] = resource_url(request)
|
|
207
|
+
p['scope'] = _scope(p.get('scope'))
|
|
208
|
+
request_id = secrets.token_hex(32)
|
|
209
|
+
db = get_db()
|
|
210
|
+
with db.atomic():
|
|
211
|
+
db.sql('DELETE FROM "MCP OAuth Request" WHERE expires_at < ?', [int(time.time())])
|
|
212
|
+
db.sql('INSERT INTO "MCP OAuth Request" (id,params,expires_at) VALUES (?,?,?)',
|
|
213
|
+
[request_id, json.dumps(p), int(time.time()) + 600])
|
|
214
|
+
response = RedirectResponse('/connect/authorize?request=' + request_id, status_code=303, headers=NO_STORE)
|
|
215
|
+
https = issuer(request).startswith('https://')
|
|
216
|
+
# Apple's social sign-in returns with a cross-site form POST.
|
|
217
|
+
response.set_cookie(RETURN_COOKIE, request_id, max_age=600, httponly=True,
|
|
218
|
+
secure=https, samesite='none' if https else 'lax', path='/api/auth')
|
|
219
|
+
return response
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _pending(request_id):
|
|
223
|
+
rows = get_db().sql('SELECT * FROM "MCP OAuth Request" WHERE id = ? AND consumed = 0 AND expires_at > ?',
|
|
224
|
+
[request_id, int(time.time())])
|
|
225
|
+
if not rows:
|
|
226
|
+
raise OAuthError('invalid_request', 'This connection request has expired. Start again in your app.')
|
|
227
|
+
return json.loads(rows[0]['params'])
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@router.get('/api/mcp-oauth/consent')
|
|
231
|
+
def consent(request: Request, request_id: str, user: dict = Depends(require_interactive_user)):
|
|
232
|
+
_enabled()
|
|
233
|
+
p = _pending(request_id)
|
|
234
|
+
client = _client(p['client_id'])
|
|
235
|
+
# This signed CSRF token binds approval to the logged-in user and request.
|
|
236
|
+
csrf = jwt.encode({'sub': user['name'], 'rid': request_id, 'aud': 'mcp-consent',
|
|
237
|
+
'exp': int(time.time()) + 600}, SECRET_KEY, algorithm=ALGORITHM)
|
|
238
|
+
maximum = min(ROLE_HIERARCHY[user['role']], ROLE_HIERARCHY[_role(p['scope'])])
|
|
239
|
+
return JSONResponse({'client_name': client['name'], 'redirect_uri': p['redirect_uri'],
|
|
240
|
+
'roles': [r for r in ('viewer', 'manager', 'admin') if ROLE_HIERARCHY[r] <= maximum],
|
|
241
|
+
'csrf_token': csrf}, headers=NO_STORE)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
class ConsentDecision(BaseModel):
|
|
245
|
+
request_id: str
|
|
246
|
+
csrf_token: str
|
|
247
|
+
allow: bool
|
|
248
|
+
role: str = 'viewer'
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
@router.post('/api/mcp-oauth/consent')
|
|
252
|
+
def approve(data: ConsentDecision, request: Request, user: dict = Depends(require_interactive_user)):
|
|
253
|
+
_enabled()
|
|
254
|
+
origin = request.headers.get('origin')
|
|
255
|
+
if origin and origin != issuer(request):
|
|
256
|
+
raise OAuthError('access_denied', 'Invalid request origin.', 403)
|
|
257
|
+
try:
|
|
258
|
+
claims = jwt.decode(data.csrf_token, SECRET_KEY, algorithms=[ALGORITHM], audience='mcp-consent')
|
|
259
|
+
if claims['sub'] != user['name'] or claims['rid'] != data.request_id:
|
|
260
|
+
raise ValueError()
|
|
261
|
+
except (JWTError, KeyError, ValueError):
|
|
262
|
+
raise OAuthError('access_denied', 'Approval expired. Reload this page.', 403)
|
|
263
|
+
p = _pending(data.request_id)
|
|
264
|
+
client = _client(p['client_id'])
|
|
265
|
+
if data.allow and (data.role not in SCOPES.values() or ROLE_HIERARCHY[data.role] > min(ROLE_HIERARCHY[user['role']], ROLE_HIERARCHY[_role(p['scope'])])):
|
|
266
|
+
raise OAuthError('invalid_scope', 'The app cannot exceed your role or its requested access.')
|
|
267
|
+
query = {'state': p['state']} if 'state' in p else {}
|
|
268
|
+
db = get_db()
|
|
269
|
+
with db.atomic():
|
|
270
|
+
claimed = db.sql('UPDATE "MCP OAuth Request" SET consumed = 1 WHERE id = ? AND consumed = 0 AND expires_at > ? RETURNING id',
|
|
271
|
+
[data.request_id, int(time.time())])
|
|
272
|
+
if not claimed:
|
|
273
|
+
raise OAuthError('invalid_request', 'This approval has already been used or expired.')
|
|
274
|
+
if data.allow:
|
|
275
|
+
key_id, code = str(uuid.uuid4()), secrets.token_urlsafe(32)
|
|
276
|
+
scope = ' '.join(s for s in p['scope'].split() if ROLE_HIERARCHY[SCOPES[s]] <= ROLE_HIERARCHY[data.role]) or 'erp:read'
|
|
277
|
+
db.sql('INSERT INTO "Api Key" (id,name,owner,key_prefix,role,session_owner,created_at,app_type) VALUES (?,?,?,?,?,?,?,?)',
|
|
278
|
+
[key_id, client['name'], user['name'], 'OAuth', data.role, f"api:{user['name']}", now(), 'oauth'])
|
|
279
|
+
db.sql('INSERT INTO "MCP OAuth Code" (hash,client_id,key_id,redirect_uri,challenge,resource,scope,expires_at) VALUES (?,?,?,?,?,?,?,?)',
|
|
280
|
+
[digest(code), client['id'], key_id, p['redirect_uri'], p['code_challenge'], p['resource'], scope, int(time.time()) + 120])
|
|
281
|
+
query['code'] = code
|
|
282
|
+
else:
|
|
283
|
+
query['error'] = 'access_denied'
|
|
284
|
+
parts = urlsplit(p['redirect_uri'])
|
|
285
|
+
redirect = urlunsplit(parts._replace(query=urlencode(parse_qsl(parts.query) + list(query.items()))))
|
|
286
|
+
response = JSONResponse({'redirect_uri': redirect}, headers=NO_STORE)
|
|
287
|
+
response.delete_cookie(RETURN_COOKIE, path='/api/auth')
|
|
288
|
+
return response
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _authenticate_client(request, form):
|
|
292
|
+
client_id, secret = form.get('client_id'), form.get('client_secret', '')
|
|
293
|
+
header = request.headers.get('authorization', '')
|
|
294
|
+
basic = header.lower().startswith('basic ')
|
|
295
|
+
if basic:
|
|
296
|
+
try:
|
|
297
|
+
cid, secret = base64.b64decode(header[6:], validate=True).decode().split(':', 1)
|
|
298
|
+
cid, secret = unquote(cid), unquote(secret)
|
|
299
|
+
except (ValueError, UnicodeError):
|
|
300
|
+
raise OAuthError('invalid_client', 'Invalid client authentication.', 401)
|
|
301
|
+
if client_id and client_id != cid:
|
|
302
|
+
raise OAuthError('invalid_client', 'Conflicting client IDs.', 401)
|
|
303
|
+
client_id = cid
|
|
304
|
+
client = _client(client_id)
|
|
305
|
+
method = client['auth_method']
|
|
306
|
+
if (method == 'client_secret_basic' and not basic) or (method == 'client_secret_post' and basic):
|
|
307
|
+
raise OAuthError('invalid_client', 'Use the registered authentication method.', 401)
|
|
308
|
+
if method != 'none' and not hmac.compare_digest(digest(secret), client['secret_hash']):
|
|
309
|
+
raise OAuthError('invalid_client', 'Invalid client authentication.', 401)
|
|
310
|
+
return client
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
async def _token_form(request):
|
|
314
|
+
if len(await request.body()) > 16384:
|
|
315
|
+
raise OAuthError('invalid_request', 'Token request is too large.')
|
|
316
|
+
form = await request.form()
|
|
317
|
+
if any(len(form.getlist(k)) != 1 for k in form) or any(not isinstance(v, str) or len(v) > 4096 for v in form.values()):
|
|
318
|
+
raise OAuthError('invalid_request', 'Invalid token parameters.')
|
|
319
|
+
return form
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _grant_user(key_id):
|
|
323
|
+
principal = refresh_auth_principal({'credential_type': API_KEY_CREDENTIAL, 'api_key_id': key_id})
|
|
324
|
+
if not principal or principal['role'] == 'public_manager':
|
|
325
|
+
raise OAuthError('invalid_grant', 'Connection revoked or account no longer available.')
|
|
326
|
+
return principal
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _issue_tokens(db, grant):
|
|
330
|
+
access, refresh = 'erp_oauth_' + secrets.token_urlsafe(32), secrets.token_urlsafe(48)
|
|
331
|
+
for token, kind, ttl in ((access, 'access', ACCESS_TTL), (refresh, 'refresh', REFRESH_TTL)):
|
|
332
|
+
db.sql('INSERT INTO "MCP OAuth Token" (hash,kind,client_id,key_id,resource,scope,expires_at) VALUES (?,?,?,?,?,?,?)',
|
|
333
|
+
[digest(token), kind, grant['client_id'], grant['key_id'], grant['resource'], grant['scope'], int(time.time()) + ttl])
|
|
334
|
+
return {'access_token': access, 'token_type': 'Bearer', 'expires_in': ACCESS_TTL,
|
|
335
|
+
'refresh_token': refresh, 'scope': grant['scope']}
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
@router.post('/api/mcp-oauth/token')
|
|
339
|
+
async def token_endpoint(request: Request):
|
|
340
|
+
_enabled()
|
|
341
|
+
form = await _token_form(request)
|
|
342
|
+
client = _authenticate_client(request, form)
|
|
343
|
+
kind = form.get('grant_type')
|
|
344
|
+
if kind not in {'authorization_code', 'refresh_token'}:
|
|
345
|
+
raise OAuthError('unsupported_grant_type', 'Use authorization_code or refresh_token.')
|
|
346
|
+
db = get_db()
|
|
347
|
+
table = 'MCP OAuth Code' if kind == 'authorization_code' else 'MCP OAuth Token'
|
|
348
|
+
token_hash = digest(form.get('code' if kind == 'authorization_code' else 'refresh_token', ''))
|
|
349
|
+
rows = db.sql(f'SELECT * FROM "{table}" WHERE hash = ? AND client_id = ?', [token_hash, client['id']])
|
|
350
|
+
if not rows or rows[0]['expires_at'] <= time.time():
|
|
351
|
+
raise OAuthError('invalid_grant', 'Invalid or expired credential.')
|
|
352
|
+
grant = rows[0]
|
|
353
|
+
if grant['resource'] != resource_url(request) or form.get('resource', grant['resource']) != grant['resource']:
|
|
354
|
+
raise OAuthError('invalid_target', 'Token is bound to a different MCP resource.')
|
|
355
|
+
if kind == 'authorization_code':
|
|
356
|
+
verifier = form.get('code_verifier', '')
|
|
357
|
+
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).decode().rstrip('=')
|
|
358
|
+
if (not re.fullmatch(r'[A-Za-z0-9._~-]{43,128}', verifier)
|
|
359
|
+
or not hmac.compare_digest(challenge, grant['challenge'])
|
|
360
|
+
or form.get('redirect_uri') != grant['redirect_uri']):
|
|
361
|
+
raise OAuthError('invalid_grant', 'Invalid PKCE verifier or redirect URI.')
|
|
362
|
+
elif grant['kind'] != 'refresh':
|
|
363
|
+
raise OAuthError('invalid_grant', 'Expected a refresh token.')
|
|
364
|
+
if grant['consumed']:
|
|
365
|
+
# A replayed refresh token invalidates the entire grant/token family.
|
|
366
|
+
if kind == 'refresh_token':
|
|
367
|
+
with db.atomic():
|
|
368
|
+
db.sql('UPDATE "Api Key" SET revoked = 1 WHERE id = ?', [grant['key_id']])
|
|
369
|
+
raise OAuthError('invalid_grant', 'Credential already used. Connect again.')
|
|
370
|
+
_grant_user(grant['key_id'])
|
|
371
|
+
if form.get('scope'):
|
|
372
|
+
narrowed = _scope(form['scope'])
|
|
373
|
+
if not set(narrowed.split()) <= set(grant['scope'].split()):
|
|
374
|
+
raise OAuthError('invalid_scope', 'Refresh cannot expand permissions.')
|
|
375
|
+
grant['scope'] = narrowed
|
|
376
|
+
with db.atomic():
|
|
377
|
+
claimed = db.sql(f'UPDATE "{table}" SET consumed = 1 WHERE hash = ? AND consumed = 0 AND expires_at > ? RETURNING hash',
|
|
378
|
+
[token_hash, int(time.time())])
|
|
379
|
+
if claimed:
|
|
380
|
+
result = _issue_tokens(db, grant)
|
|
381
|
+
else:
|
|
382
|
+
# A second worker may have consumed the same refresh token after
|
|
383
|
+
# our initial read. Commit family revocation before returning error.
|
|
384
|
+
if kind == 'refresh_token':
|
|
385
|
+
db.sql('UPDATE "Api Key" SET revoked = 1 WHERE id = ?', [grant['key_id']])
|
|
386
|
+
result = None
|
|
387
|
+
if result is None:
|
|
388
|
+
raise OAuthError('invalid_grant', 'Credential already used or expired.')
|
|
389
|
+
return JSONResponse(result, headers=NO_STORE)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def authenticate_access_token(request, token):
|
|
393
|
+
_enabled()
|
|
394
|
+
rows = get_db().sql('SELECT * FROM "MCP OAuth Token" WHERE hash = ? AND kind = ? AND expires_at > ?',
|
|
395
|
+
[digest(token), 'access', int(time.time())])
|
|
396
|
+
if not rows or rows[0]['resource'] != resource_url(request):
|
|
397
|
+
raise HTTPException(401, 'Invalid or expired OAuth access token', headers=challenge_header(request))
|
|
398
|
+
grant = rows[0]
|
|
399
|
+
try:
|
|
400
|
+
user = _grant_user(grant['key_id'])
|
|
401
|
+
except OAuthError:
|
|
402
|
+
raise HTTPException(401, 'OAuth connection revoked', headers=challenge_header(request))
|
|
403
|
+
user['role'] = min((user['role'], _role(grant['scope'])), key=lambda r: ROLE_HIERARCHY[r])
|
|
404
|
+
user['oauth_scope_role'] = _role(grant['scope'])
|
|
405
|
+
# Return a user principal, not the isolated programmatic chat identity.
|
|
406
|
+
user['name'] = user['user_id']
|
|
407
|
+
db = get_db()
|
|
408
|
+
with db.atomic():
|
|
409
|
+
db.sql('UPDATE "Api Key" SET last_used_at = ? WHERE id = ?', [now(), grant['key_id']])
|
|
410
|
+
return user
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
@router.post('/api/mcp-oauth/revoke')
|
|
414
|
+
async def revoke_token(request: Request):
|
|
415
|
+
form = await _token_form(request)
|
|
416
|
+
client = _authenticate_client(request, form)
|
|
417
|
+
rows = get_db().sql('SELECT key_id FROM "MCP OAuth Token" WHERE hash = ? AND client_id = ?',
|
|
418
|
+
[digest(form.get('token', '')), client['id']])
|
|
419
|
+
if rows:
|
|
420
|
+
db = get_db()
|
|
421
|
+
with db.atomic():
|
|
422
|
+
db.sql('UPDATE "Api Key" SET revoked = 1 WHERE id = ?', [rows[0]['key_id']])
|
|
423
|
+
return JSONResponse({}, headers=NO_STORE)
|
|
@@ -29,6 +29,7 @@ import uuid
|
|
|
29
29
|
import hmac
|
|
30
30
|
import hashlib
|
|
31
31
|
import secrets as _secrets
|
|
32
|
+
import re
|
|
32
33
|
|
|
33
34
|
import httpx
|
|
34
35
|
from jose import jwt, JWTError
|
|
@@ -474,7 +475,10 @@ async def _read_callback_params(request: Request) -> dict:
|
|
|
474
475
|
|
|
475
476
|
def _login_redirect(request: Request, user_name: str, flow_id: str | None = None) -> RedirectResponse:
|
|
476
477
|
token = create_access_token(user_name)
|
|
477
|
-
|
|
478
|
+
mcp_request = request.cookies.get('lambda_erp_mcp_return', '')
|
|
479
|
+
target = '/connect/authorize?request=' + mcp_request if re.fullmatch(r'[a-f0-9]{64}', mcp_request) else '/'
|
|
480
|
+
resp = RedirectResponse(url=target, status_code=303)
|
|
481
|
+
resp.delete_cookie('lambda_erp_mcp_return', path='/api/auth')
|
|
478
482
|
set_auth_cookie(request, resp, token)
|
|
479
483
|
if flow_id:
|
|
480
484
|
_clear_flow_cookie(resp, flow_id)
|
|
@@ -4,9 +4,9 @@ LLM agents (Claude, Codex, …) over MCP's Streamable HTTP transport.
|
|
|
4
4
|
Reuses everything the chat already has, so there's almost no new logic:
|
|
5
5
|
* schemas — build_tools() (the live, plugin-widened tool list).
|
|
6
6
|
* execution — the same TOOL_HANDLERS (which run validate()).
|
|
7
|
-
* auth —
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
* auth — Bearer API keys or resource-bound OAuth tokens, acting as their
|
|
8
|
+
owner with a live role cap. Both use `rest_api_enabled`.
|
|
9
|
+
Ambient session cookies are never MCP credentials.
|
|
10
10
|
|
|
11
11
|
Because the tool schemas come from the live registries, a plugin that registers
|
|
12
12
|
doctypes/masters (e.g. the internal CRM's lead/contact/activity) is exposed here
|
|
@@ -16,12 +16,15 @@ Transport: a single POST /api/mcp speaking JSON-RPC 2.0 (request → JSON result
|
|
|
16
16
|
notifications → 202, no body). That's the minimum a tool-only server needs.
|
|
17
17
|
"""
|
|
18
18
|
import json
|
|
19
|
+
import os
|
|
19
20
|
|
|
20
21
|
from fastapi import APIRouter, HTTPException, Request
|
|
21
22
|
from fastapi.responses import JSONResponse, Response
|
|
22
23
|
|
|
23
24
|
from lambda_erp import get_app_version
|
|
24
|
-
from api.auth import
|
|
25
|
+
from api.auth import _lookup_api_key, _setting_enabled, make_auth_principal, API_KEY_CREDENTIAL
|
|
26
|
+
from api.mcp_oauth import authenticate_access_token, challenge_header, issuer
|
|
27
|
+
from lambda_erp.database import get_db
|
|
25
28
|
from api import services
|
|
26
29
|
from api import chat as chat_mod
|
|
27
30
|
from api.chat import TOOL_HANDLERS, build_tools
|
|
@@ -44,18 +47,35 @@ _EXCLUDE = {
|
|
|
44
47
|
"apply_company_setup",
|
|
45
48
|
}
|
|
46
49
|
|
|
50
|
+
_READ_ONLY = {
|
|
51
|
+
'list_documents', 'get_document_fields', 'get_document', 'get_master_fields',
|
|
52
|
+
'search_masters', 'get_report', 'get_current_time', 'query_dataset',
|
|
53
|
+
'preview_bank_statement_attachments', 'list_bank_reconciliation_queue',
|
|
54
|
+
'suggest_bank_reconciliation',
|
|
55
|
+
}
|
|
56
|
+
|
|
47
57
|
|
|
48
58
|
def _allowed(name: str, role) -> bool:
|
|
49
59
|
return name not in _EXCLUDE and tool_allowed(name, role)
|
|
50
60
|
|
|
51
61
|
|
|
52
62
|
def _require_caller(request: Request) -> dict:
|
|
53
|
-
"""
|
|
54
|
-
get_current_user then validates the key and applies `rest_api_enabled`."""
|
|
63
|
+
"""MCP accepts API keys and OAuth tokens, never ambient browser cookies."""
|
|
55
64
|
auth = request.headers.get("authorization", "")
|
|
56
65
|
if not auth.lower().startswith("bearer "):
|
|
57
|
-
raise HTTPException(status_code=401, detail="
|
|
58
|
-
|
|
66
|
+
raise HTTPException(status_code=401, detail="Connect your account or supply a Bearer API key", headers=challenge_header(request))
|
|
67
|
+
token = auth[7:].strip()
|
|
68
|
+
if token.startswith('erp_oauth_'):
|
|
69
|
+
return authenticate_access_token(request, token)
|
|
70
|
+
db = get_db()
|
|
71
|
+
if not _setting_enabled(db, 'rest_api_enabled'):
|
|
72
|
+
raise HTTPException(403, 'REST API / MCP access is disabled')
|
|
73
|
+
try:
|
|
74
|
+
key, owner, role = _lookup_api_key(db, token)
|
|
75
|
+
except HTTPException as exc:
|
|
76
|
+
raise HTTPException(exc.status_code, exc.detail, headers=challenge_header(request))
|
|
77
|
+
owner = dict(owner, role=role)
|
|
78
|
+
return make_auth_principal(owner, API_KEY_CREDENTIAL, api_key_id=key['id'])
|
|
59
79
|
|
|
60
80
|
|
|
61
81
|
def _tools(role) -> list:
|
|
@@ -68,6 +88,16 @@ def _tools(role) -> list:
|
|
|
68
88
|
"name": fn["name"],
|
|
69
89
|
"description": fn.get("description", ""),
|
|
70
90
|
"inputSchema": fn.get("parameters") or {"type": "object", "properties": {}},
|
|
91
|
+
"annotations": {
|
|
92
|
+
"readOnlyHint": fn['name'] in _READ_ONLY,
|
|
93
|
+
# Unknown extension actions are conservatively treated as writes.
|
|
94
|
+
"destructiveHint": fn['name'] not in _READ_ONLY | {'generate_document_pdf'},
|
|
95
|
+
"openWorldHint": fn['name'] in services.REGISTERED_ACTIONS,
|
|
96
|
+
},
|
|
97
|
+
"securitySchemes": [{"type": "oauth2", "scopes": [
|
|
98
|
+
'erp:read' if tool_allowed(fn['name'], 'viewer') else
|
|
99
|
+
'erp:write' if tool_allowed(fn['name'], 'manager') else 'erp:admin'
|
|
100
|
+
]}],
|
|
71
101
|
})
|
|
72
102
|
return out
|
|
73
103
|
|
|
@@ -116,6 +146,10 @@ def _rpc_error(mid, code: int, message: str) -> dict:
|
|
|
116
146
|
def _handle(msg: dict, user: dict):
|
|
117
147
|
"""Handle one JSON-RPC message. Returns a response dict, or None for a
|
|
118
148
|
notification (no `id`)."""
|
|
149
|
+
if msg.get('jsonrpc') != '2.0' or not isinstance(msg.get('method'), str):
|
|
150
|
+
return _rpc_error(msg.get('id'), -32600, 'Invalid Request')
|
|
151
|
+
if 'params' in msg and not isinstance(msg['params'], dict):
|
|
152
|
+
return _rpc_error(msg.get('id'), -32602, 'Expected object parameters')
|
|
119
153
|
method = msg.get("method")
|
|
120
154
|
mid = msg.get("id")
|
|
121
155
|
is_notification = "id" not in msg
|
|
@@ -156,13 +190,22 @@ def _handle(msg: dict, user: dict):
|
|
|
156
190
|
|
|
157
191
|
@router.post("/mcp")
|
|
158
192
|
async def mcp_endpoint(request: Request):
|
|
193
|
+
origin = request.headers.get('origin')
|
|
194
|
+
allowed_origins = {issuer(request), *os.environ.get('MCP_ALLOWED_ORIGINS', '').split(',')}
|
|
195
|
+
if origin and origin not in allowed_origins:
|
|
196
|
+
raise HTTPException(403, 'Invalid Origin')
|
|
197
|
+
version = request.headers.get('mcp-protocol-version')
|
|
198
|
+
if version and version not in {'2025-03-26', PROTOCOL_VERSION}:
|
|
199
|
+
raise HTTPException(400, 'Unsupported MCP protocol version')
|
|
159
200
|
user = _require_caller(request)
|
|
160
201
|
try:
|
|
161
202
|
body = await request.json()
|
|
162
203
|
except Exception:
|
|
163
204
|
return JSONResponse(_rpc_error(None, -32700, "Parse error"), status_code=400)
|
|
164
205
|
|
|
165
|
-
#
|
|
206
|
+
# Keep legacy batch support, but reject malformed messages rather than 500.
|
|
207
|
+
if not isinstance(body, (dict, list)) or (isinstance(body, list) and (not body or any(not isinstance(m, dict) for m in body))):
|
|
208
|
+
return JSONResponse(_rpc_error(None, -32600, "Invalid Request"), status_code=400)
|
|
166
209
|
if isinstance(body, list):
|
|
167
210
|
responses = [r for r in (_handle(m, user) for m in body) if r is not None]
|
|
168
211
|
return JSONResponse(responses) if responses else Response(status_code=202)
|
|
@@ -170,3 +213,9 @@ async def mcp_endpoint(request: Request):
|
|
|
170
213
|
if resp is None:
|
|
171
214
|
return Response(status_code=202)
|
|
172
215
|
return JSONResponse(resp)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@router.get('/mcp')
|
|
219
|
+
def mcp_no_stream():
|
|
220
|
+
# A stateless JSON transport does not offer a server-initiated SSE stream.
|
|
221
|
+
return Response(status_code=405, headers={'Allow': 'POST'})
|
|
@@ -57,7 +57,7 @@ export async function request<T>(path: string, options?: RequestInit): Promise<T
|
|
|
57
57
|
throw new ApiError(401, "Session expired");
|
|
58
58
|
}
|
|
59
59
|
const body = await res.json().catch(() => ({ detail: res.statusText }));
|
|
60
|
-
throw new ApiError(res.status, formatErrorDetail(body.detail));
|
|
60
|
+
throw new ApiError(res.status, formatErrorDetail(body.detail || body.error_description));
|
|
61
61
|
}
|
|
62
62
|
return res.json();
|
|
63
63
|
}
|
|
@@ -74,7 +74,7 @@ async function multipartRequest<T>(path: string, form: FormData): Promise<T> {
|
|
|
74
74
|
throw new ApiError(401, "Session expired");
|
|
75
75
|
}
|
|
76
76
|
const body = await res.json().catch(() => ({ detail: res.statusText }));
|
|
77
|
-
throw new ApiError(res.status, formatErrorDetail(body.detail));
|
|
77
|
+
throw new ApiError(res.status, formatErrorDetail(body.detail || body.error_description));
|
|
78
78
|
}
|
|
79
79
|
return res.json() as Promise<T>;
|
|
80
80
|
}
|
|
@@ -746,17 +746,21 @@ export const api = {
|
|
|
746
746
|
getMySettings: () => request<Record<string, string>>("/auth/my-settings"),
|
|
747
747
|
updateMySettings: (data: Record<string, string>) =>
|
|
748
748
|
request<Record<string, string>>("/auth/my-settings", { method: "PUT", body: JSON.stringify(data) }),
|
|
749
|
+
getConnectionStatus: () => request<{ mcp_url: string; rest_enabled: boolean; chat_enabled: boolean }>("/mcp-oauth/status"),
|
|
750
|
+
getMcpConsent: (requestId: string) => request<{ client_name: string; redirect_uri: string; roles: string[]; csrf_token: string }>(`/mcp-oauth/consent?request_id=${encodeURIComponent(requestId)}`),
|
|
751
|
+
approveMcp: (request_id: string, csrf_token: string, allow: boolean, role: string) =>
|
|
752
|
+
request<{ redirect_uri: string }>("/mcp-oauth/consent", { method: "POST", body: JSON.stringify({ request_id, csrf_token, allow, role }) }),
|
|
749
753
|
getApiKeys: () =>
|
|
750
754
|
request<Array<{
|
|
751
755
|
id: string; name: string; user?: string; role: string; key_prefix: string;
|
|
752
|
-
created_at?: string; last_used_at?: string | null; revoked: boolean;
|
|
756
|
+
created_at?: string; last_used_at?: string | null; revoked: boolean; app_type?: string;
|
|
753
757
|
owner_full_name?: string | null; owner_email?: string | null; is_mine?: boolean;
|
|
754
758
|
}>>("/auth/api-keys"),
|
|
755
|
-
createApiKey: (name: string, role: string) =>
|
|
759
|
+
createApiKey: (name: string, role: string, app_type: "claude_code" | "codex" | "other" = "other") =>
|
|
756
760
|
request<{
|
|
757
761
|
id: string; name: string; user?: string; role: string; key_prefix: string; token: string;
|
|
758
762
|
owner_full_name?: string | null; owner_email?: string | null; is_mine?: boolean;
|
|
759
|
-
}>("/auth/api-keys", { method: "POST", body: JSON.stringify({ name, role }) }),
|
|
763
|
+
}>("/auth/api-keys", { method: "POST", body: JSON.stringify({ name, role, app_type }) }),
|
|
760
764
|
revokeApiKey: (id: string) =>
|
|
761
765
|
request<{ id: string; revoked: boolean }>(
|
|
762
766
|
`/auth/api-keys/${encodeURIComponent(id)}/revoke`, { method: "POST" }),
|
|
@@ -1695,6 +1695,25 @@ class Database:
|
|
|
1695
1695
|
revoked INTEGER DEFAULT 0
|
|
1696
1696
|
)""",
|
|
1697
1697
|
|
|
1698
|
+
"""CREATE TABLE IF NOT EXISTS "MCP OAuth Client" (
|
|
1699
|
+
id TEXT PRIMARY KEY, name TEXT NOT NULL, redirect_uris TEXT NOT NULL,
|
|
1700
|
+
auth_method TEXT NOT NULL, secret_hash TEXT, created_at INTEGER NOT NULL
|
|
1701
|
+
)""",
|
|
1702
|
+
"""CREATE TABLE IF NOT EXISTS "MCP OAuth Request" (
|
|
1703
|
+
id TEXT PRIMARY KEY, params TEXT NOT NULL, expires_at INTEGER NOT NULL,
|
|
1704
|
+
consumed INTEGER DEFAULT 0
|
|
1705
|
+
)""",
|
|
1706
|
+
"""CREATE TABLE IF NOT EXISTS "MCP OAuth Code" (
|
|
1707
|
+
hash TEXT PRIMARY KEY, client_id TEXT NOT NULL, key_id TEXT NOT NULL,
|
|
1708
|
+
redirect_uri TEXT NOT NULL, challenge TEXT NOT NULL, resource TEXT NOT NULL,
|
|
1709
|
+
scope TEXT NOT NULL, expires_at INTEGER NOT NULL, consumed INTEGER DEFAULT 0
|
|
1710
|
+
)""",
|
|
1711
|
+
"""CREATE TABLE IF NOT EXISTS "MCP OAuth Token" (
|
|
1712
|
+
hash TEXT PRIMARY KEY, kind TEXT NOT NULL, client_id TEXT NOT NULL,
|
|
1713
|
+
key_id TEXT NOT NULL, resource TEXT NOT NULL, scope TEXT NOT NULL,
|
|
1714
|
+
expires_at INTEGER NOT NULL, consumed INTEGER DEFAULT 0
|
|
1715
|
+
)""",
|
|
1716
|
+
|
|
1698
1717
|
# Exchange rates for multi-currency. A lookup carries forward the
|
|
1699
1718
|
# most recent rate on/before a transaction's date; the rate is then
|
|
1700
1719
|
# snapshotted onto the document, so editing this table never changes
|
|
@@ -2855,6 +2874,10 @@ def _m038_customer_website(db: "Database") -> None:
|
|
|
2855
2874
|
db.ensure_column("Customer", "website", "TEXT")
|
|
2856
2875
|
|
|
2857
2876
|
|
|
2877
|
+
def _m039_connection_app_type(db: "Database") -> None:
|
|
2878
|
+
db.ensure_column("Api Key", "app_type", "TEXT DEFAULT 'other'")
|
|
2879
|
+
|
|
2880
|
+
|
|
2858
2881
|
Database.MIGRATIONS = [
|
|
2859
2882
|
(1, "chat_message_session_id", _m001_chat_message_session_id),
|
|
2860
2883
|
(2, "chat_session_user_id", _m002_chat_session_user_id),
|
|
@@ -2894,6 +2917,7 @@ Database.MIGRATIONS = [
|
|
|
2894
2917
|
(36, "external_identity_integrity", _m036_external_identity_integrity),
|
|
2895
2918
|
(37, "pricing_record_lifecycle", _m037_pricing_record_lifecycle),
|
|
2896
2919
|
(38, "customer_website", _m038_customer_website),
|
|
2920
|
+
(39, "connection_app_type", _m039_connection_app_type),
|
|
2897
2921
|
]
|
|
2898
2922
|
|
|
2899
2923
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|