deepcell-cli 0.6.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.
- deepcell_cli/__init__.py +12 -0
- deepcell_cli/__main__.py +5 -0
- deepcell_cli/_findings.py +84 -0
- deepcell_cli/capabilities.py +560 -0
- deepcell_cli/capability-contract.json +15622 -0
- deepcell_cli/client.py +503 -0
- deepcell_cli/commands/__init__.py +1 -0
- deepcell_cli/commands/_batch_input.py +29 -0
- deepcell_cli/commands/_datatypes.py +56 -0
- deepcell_cli/commands/_negative_args.py +133 -0
- deepcell_cli/commands/_swapped_args.py +153 -0
- deepcell_cli/commands/_version_display.py +40 -0
- deepcell_cli/commands/_write_opts.py +139 -0
- deepcell_cli/commands/account.py +123 -0
- deepcell_cli/commands/auth.py +610 -0
- deepcell_cli/commands/changes.py +307 -0
- deepcell_cli/commands/deck.py +594 -0
- deepcell_cli/commands/defs.py +3890 -0
- deepcell_cli/commands/describe.py +902 -0
- deepcell_cli/commands/doc.py +529 -0
- deepcell_cli/commands/doctor.py +257 -0
- deepcell_cli/commands/download.py +36 -0
- deepcell_cli/commands/edit.py +384 -0
- deepcell_cli/commands/example.py +161 -0
- deepcell_cli/commands/export.py +81 -0
- deepcell_cli/commands/export_docx.py +57 -0
- deepcell_cli/commands/export_pdf.py +66 -0
- deepcell_cli/commands/export_pptx.py +45 -0
- deepcell_cli/commands/files.py +386 -0
- deepcell_cli/commands/grep.py +90 -0
- deepcell_cli/commands/guide.py +431 -0
- deepcell_cli/commands/help_cmd.py +348 -0
- deepcell_cli/commands/impact.py +382 -0
- deepcell_cli/commands/import_cmd.py +208 -0
- deepcell_cli/commands/ingest.py +110 -0
- deepcell_cli/commands/merge.py +399 -0
- deepcell_cli/commands/query.py +718 -0
- deepcell_cli/commands/reasoning.py +2981 -0
- deepcell_cli/commands/ref.py +279 -0
- deepcell_cli/commands/replace.py +326 -0
- deepcell_cli/commands/rules.py +206 -0
- deepcell_cli/commands/share.py +186 -0
- deepcell_cli/commands/sync.py +804 -0
- deepcell_cli/commands/upgrade.py +185 -0
- deepcell_cli/commands/variant.py +353 -0
- deepcell_cli/commands/version.py +445 -0
- deepcell_cli/commands/viewer.py +54 -0
- deepcell_cli/commands/workspace.py +101 -0
- deepcell_cli/config.py +352 -0
- deepcell_cli/context.py +187 -0
- deepcell_cli/errors.py +141 -0
- deepcell_cli/logging_setup.py +161 -0
- deepcell_cli/main.py +518 -0
- deepcell_cli/mcp_server.py +906 -0
- deepcell_cli/oauth_provider.py +580 -0
- deepcell_cli/output.py +503 -0
- deepcell_cli/revision.py +164 -0
- deepcell_cli/stages.py +223 -0
- deepcell_cli/surface.py +628 -0
- deepcell_cli/sync_state.py +120 -0
- deepcell_cli/upgrade_check.py +399 -0
- deepcell_cli/xml_replace.py +89 -0
- deepcell_cli-0.6.1.dist-info/METADATA +264 -0
- deepcell_cli-0.6.1.dist-info/RECORD +67 -0
- deepcell_cli-0.6.1.dist-info/WHEEL +5 -0
- deepcell_cli-0.6.1.dist-info/entry_points.txt +3 -0
- deepcell_cli-0.6.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
"""OAuth 2.1 provider for MCP remote authentication.
|
|
2
|
+
|
|
3
|
+
Implements the ``OAuthAuthorizationServerProvider`` protocol from the MCP SDK
|
|
4
|
+
so that AI platforms (Claude.ai, ChatGPT, Manus) can authenticate users via
|
|
5
|
+
OAuth 2.1 + PKCE before accessing DeepCell tools.
|
|
6
|
+
|
|
7
|
+
Storage:
|
|
8
|
+
- Client registrations: ``~/.deepcell/oauth_clients.json`` (persistent; the
|
|
9
|
+
``deepcell-mcp`` container mounts a volume here so DCR survives redeploys).
|
|
10
|
+
- Authorization codes: in-memory dict with 10-min TTL.
|
|
11
|
+
- Refresh tokens: delegated to the Jingwei API. An MCP refresh token *is* a
|
|
12
|
+
Jingwei refresh token (opaque, hashed in Postgres), so rotation, token-family
|
|
13
|
+
reuse-detection and revocation live server-side and survive container
|
|
14
|
+
recreates — there is no in-memory refresh-token store.
|
|
15
|
+
- Access tokens: JWT issued by Jingwei and verified locally using the same
|
|
16
|
+
shared secret.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
import secrets
|
|
24
|
+
import time
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any
|
|
27
|
+
from urllib.parse import quote, urlparse
|
|
28
|
+
from uuid import UUID
|
|
29
|
+
|
|
30
|
+
import httpx
|
|
31
|
+
import jwt as pyjwt
|
|
32
|
+
|
|
33
|
+
from mcp.server.auth.provider import (
|
|
34
|
+
AccessToken,
|
|
35
|
+
AuthorizationCode,
|
|
36
|
+
AuthorizationParams,
|
|
37
|
+
AuthorizeError,
|
|
38
|
+
OAuthAuthorizationServerProvider,
|
|
39
|
+
RefreshToken,
|
|
40
|
+
TokenError,
|
|
41
|
+
construct_redirect_uri,
|
|
42
|
+
)
|
|
43
|
+
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
|
|
44
|
+
|
|
45
|
+
from deepcell_cli.config import _DIR, _ensure_dir, _write_private_file, client_headers
|
|
46
|
+
|
|
47
|
+
logger = logging.getLogger(__name__)
|
|
48
|
+
|
|
49
|
+
_CLIENTS_FILE = _DIR / "oauth_clients.json"
|
|
50
|
+
|
|
51
|
+
# Authorization codes expire after 10 minutes (RFC 6749 §4.1.2).
|
|
52
|
+
_AUTH_CODE_TTL = 600
|
|
53
|
+
|
|
54
|
+
# A pending authorization is a browser mid-consent, so it lives about as long as
|
|
55
|
+
# a person takes to sign in. Without a TTL a `session_id` stayed valid until the
|
|
56
|
+
# process restarted, which both kept phishing links alive indefinitely and let
|
|
57
|
+
# `/authorize` — reachable without any credential — grow the map without bound
|
|
58
|
+
# in a 512 MB container. The cap is the backstop for a burst inside one window.
|
|
59
|
+
_PENDING_AUTH_TTL = 900
|
|
60
|
+
_MAX_PENDING_AUTH = 2000
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class TransientAuthError(RuntimeError):
|
|
64
|
+
"""The Jingwei auth backend was *transiently* unavailable (network error,
|
|
65
|
+
timeout, or 5xx) during a token exchange.
|
|
66
|
+
|
|
67
|
+
Distinct from ``TokenError(invalid_grant)`` on purpose. The MCP SDK's token
|
|
68
|
+
handler only catches ``TokenError`` (serialized as HTTP 400), so this
|
|
69
|
+
propagates as an HTTP 500 instead. That difference matters to the client:
|
|
70
|
+
|
|
71
|
+
- ``invalid_grant`` means "your grant is permanently bad" → a well-behaved
|
|
72
|
+
client discards the refresh token and forces a full re-authorization.
|
|
73
|
+
- a 5xx means "server problem, try again" → the client retries and KEEPS its
|
|
74
|
+
refresh token.
|
|
75
|
+
|
|
76
|
+
Mapping a transient outage to ``invalid_grant`` would log everyone out during
|
|
77
|
+
exactly the redeploy window #833 is meant to make seamless, so transient
|
|
78
|
+
failures must never be reported as a bad grant.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ── Persistent client store ──────────────────────────────────
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _load_clients() -> dict[str, dict[str, Any]]:
|
|
86
|
+
if _CLIENTS_FILE.exists():
|
|
87
|
+
try:
|
|
88
|
+
return json.loads(_CLIENTS_FILE.read_text())
|
|
89
|
+
except (json.JSONDecodeError, OSError):
|
|
90
|
+
return {}
|
|
91
|
+
return {}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _save_clients(clients: dict[str, dict[str, Any]]) -> None:
|
|
95
|
+
_ensure_dir()
|
|
96
|
+
_write_private_file(_CLIENTS_FILE, json.dumps(clients, indent=2) + "\n")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ── Extended models with user identity ───────────────────────
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class DeepCellAuthorizationCode(AuthorizationCode):
|
|
103
|
+
"""Authorization code carrying the authenticated user_id and the Jingwei
|
|
104
|
+
refresh token issued at login (reused at code-exchange time)."""
|
|
105
|
+
|
|
106
|
+
user_id: str
|
|
107
|
+
jingwei_refresh_token: str
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class DeepCellAccessToken(AccessToken):
|
|
111
|
+
"""Access token enriched with user identity from JWT claims."""
|
|
112
|
+
|
|
113
|
+
user_id: str
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class DeepCellRefreshToken(RefreshToken):
|
|
117
|
+
"""Refresh token wrapper. ``user_id`` is optional: when delegating to
|
|
118
|
+
Jingwei the user is resolved server-side from the token, so we don't always
|
|
119
|
+
know it locally (e.g. in ``load_refresh_token``)."""
|
|
120
|
+
|
|
121
|
+
user_id: str = ""
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ── Provider ─────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class DeepCellOAuthProvider(
|
|
128
|
+
OAuthAuthorizationServerProvider[
|
|
129
|
+
DeepCellAuthorizationCode,
|
|
130
|
+
DeepCellRefreshToken,
|
|
131
|
+
DeepCellAccessToken,
|
|
132
|
+
]
|
|
133
|
+
):
|
|
134
|
+
"""MCP OAuth 2.1 provider backed by Jingwei API for user auth."""
|
|
135
|
+
|
|
136
|
+
def __init__(
|
|
137
|
+
self,
|
|
138
|
+
*,
|
|
139
|
+
api_url: str,
|
|
140
|
+
server_url: str,
|
|
141
|
+
jwt_secret: str,
|
|
142
|
+
frontend_url: str | None = None,
|
|
143
|
+
jwt_algorithm: str = "HS256",
|
|
144
|
+
jwt_issuer: str = "deepcell-api",
|
|
145
|
+
jwt_audience: str = "deepcell-user",
|
|
146
|
+
) -> None:
|
|
147
|
+
self.api_url = api_url.rstrip("/")
|
|
148
|
+
self.server_url = server_url.rstrip("/")
|
|
149
|
+
# Where the consent UI lives. Defaults to this server's own origin
|
|
150
|
+
# because nginx serves the Next.js frontend at `/` on the same host
|
|
151
|
+
# that proxies `/mcp` and `/oauth/*` here.
|
|
152
|
+
self.frontend_url = (frontend_url or server_url).rstrip("/")
|
|
153
|
+
self.jwt_secret = jwt_secret
|
|
154
|
+
self.jwt_algorithm = jwt_algorithm
|
|
155
|
+
self.jwt_issuer = jwt_issuer
|
|
156
|
+
self.jwt_audience = jwt_audience
|
|
157
|
+
|
|
158
|
+
# In-memory stores. Refresh tokens are NOT stored here — they are
|
|
159
|
+
# delegated to Jingwei (see ``_jingwei_refresh``).
|
|
160
|
+
self._clients: dict[str, dict[str, Any]] = _load_clients()
|
|
161
|
+
self._auth_codes: dict[str, DeepCellAuthorizationCode] = {}
|
|
162
|
+
# session_id -> (client, params, created_at). Pruned by _prune_pending.
|
|
163
|
+
self._pending_auth: dict[
|
|
164
|
+
str, tuple[OAuthClientInformationFull, AuthorizationParams, float]
|
|
165
|
+
] = {}
|
|
166
|
+
self._api_key_cache: dict[str, tuple[DeepCellAccessToken, float]] = {}
|
|
167
|
+
|
|
168
|
+
# ── Client registration (DCR) ────────────────────────────
|
|
169
|
+
|
|
170
|
+
async def get_client(self, client_id: str) -> OAuthClientInformationFull | None:
|
|
171
|
+
data = self._clients.get(client_id)
|
|
172
|
+
if data is None:
|
|
173
|
+
return None
|
|
174
|
+
return OAuthClientInformationFull.model_validate(data)
|
|
175
|
+
|
|
176
|
+
async def register_client(
|
|
177
|
+
self, client_info: OAuthClientInformationFull
|
|
178
|
+
) -> None:
|
|
179
|
+
if client_info.client_id is None:
|
|
180
|
+
client_info.client_id = f"dc_{secrets.token_urlsafe(16)}"
|
|
181
|
+
if client_info.client_secret is None:
|
|
182
|
+
client_info.client_secret = secrets.token_urlsafe(32)
|
|
183
|
+
client_info.client_id_issued_at = int(time.time())
|
|
184
|
+
|
|
185
|
+
self._clients[client_info.client_id] = client_info.model_dump(
|
|
186
|
+
mode="json", exclude_none=True
|
|
187
|
+
)
|
|
188
|
+
_save_clients(self._clients)
|
|
189
|
+
|
|
190
|
+
# ── Authorization ────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
async def authorize(
|
|
193
|
+
self,
|
|
194
|
+
client: OAuthClientInformationFull,
|
|
195
|
+
params: AuthorizationParams,
|
|
196
|
+
) -> str:
|
|
197
|
+
session_id = secrets.token_urlsafe(20)
|
|
198
|
+
self._pending_auth[session_id] = (client, params, time.time())
|
|
199
|
+
# Prune AFTER inserting so the cap holds including this entry, and so
|
|
200
|
+
# oldest-first eviction can never drop the session we just handed out.
|
|
201
|
+
self._prune_pending()
|
|
202
|
+
# Hand the browser to the shared consent page on the frontend, which
|
|
203
|
+
# renders the same AuthPanel as every other sign-in surface (Google
|
|
204
|
+
# included — this server can only check passwords, and OAuth-only
|
|
205
|
+
# accounts have none). The OAuth state machine stays here: the page
|
|
206
|
+
# comes back to POST /oauth/complete with an authorized device code.
|
|
207
|
+
return f"{self.frontend_url}/mcp-auth?session_id={quote(session_id, safe='')}"
|
|
208
|
+
|
|
209
|
+
def _prune_pending(self) -> None:
|
|
210
|
+
"""Drop expired pending authorizations, then enforce the hard cap.
|
|
211
|
+
|
|
212
|
+
Called on every ``authorize()`` because that is the only unauthenticated
|
|
213
|
+
entry point that grows the map.
|
|
214
|
+
"""
|
|
215
|
+
cutoff = time.time() - _PENDING_AUTH_TTL
|
|
216
|
+
for sid in [s for s, (_, _, ts) in self._pending_auth.items() if ts < cutoff]:
|
|
217
|
+
self._pending_auth.pop(sid, None)
|
|
218
|
+
|
|
219
|
+
# Still over cap after pruning: a burst inside one TTL window. Evict
|
|
220
|
+
# oldest-first (dicts preserve insertion order, and insertion order is
|
|
221
|
+
# creation order here) so a flood can't pin memory until restart.
|
|
222
|
+
overflow = len(self._pending_auth) - _MAX_PENDING_AUTH
|
|
223
|
+
if overflow > 0:
|
|
224
|
+
logger.warning(
|
|
225
|
+
"pending-authorization cap reached; evicting %d oldest", overflow
|
|
226
|
+
)
|
|
227
|
+
for sid in list(self._pending_auth)[:overflow]:
|
|
228
|
+
self._pending_auth.pop(sid, None)
|
|
229
|
+
|
|
230
|
+
def describe_pending(self, session_id: str) -> dict[str, Any] | None:
|
|
231
|
+
"""Consent-screen metadata for a pending authorization.
|
|
232
|
+
|
|
233
|
+
Returns ``None`` when the session is unknown, expired, or already
|
|
234
|
+
consumed, so the page can show "expired" instead of a form that cannot
|
|
235
|
+
succeed.
|
|
236
|
+
|
|
237
|
+
``redirect_host`` is the load-bearing field. Dynamic client registration
|
|
238
|
+
is open, so ``client_name`` and ``client_uri`` are free text chosen by
|
|
239
|
+
whoever registered the client — anyone can register one calling itself
|
|
240
|
+
"Claude.ai". The registered redirect target is the only value in here
|
|
241
|
+
the client cannot lie about, because the SDK sends the code there and
|
|
242
|
+
nowhere else, so it is what actually tells a user where their grant is
|
|
243
|
+
going.
|
|
244
|
+
"""
|
|
245
|
+
pending = self._pending_auth.get(session_id)
|
|
246
|
+
if pending is None:
|
|
247
|
+
return None
|
|
248
|
+
|
|
249
|
+
client, params, created_at = pending
|
|
250
|
+
if time.time() - created_at > _PENDING_AUTH_TTL:
|
|
251
|
+
self._pending_auth.pop(session_id, None)
|
|
252
|
+
return None
|
|
253
|
+
redirect_host = ""
|
|
254
|
+
if params.redirect_uri:
|
|
255
|
+
parsed = urlparse(str(params.redirect_uri))
|
|
256
|
+
redirect_host = parsed.netloc or str(params.redirect_uri)
|
|
257
|
+
|
|
258
|
+
return {
|
|
259
|
+
"client_name": client.client_name or client.client_id or "",
|
|
260
|
+
"client_uri": str(client.client_uri) if client.client_uri else None,
|
|
261
|
+
"redirect_host": redirect_host,
|
|
262
|
+
"scopes": list(params.scopes or ["deepcell"]),
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async def redeem_device_code(self, device_code: str) -> tuple[str, str] | None:
|
|
266
|
+
"""Exchange an *authorized* device code for ``(user_id, refresh_token)``.
|
|
267
|
+
|
|
268
|
+
The consent page mints the code (``/auth/device/code``) and approves it
|
|
269
|
+
as the signed-in user (``/auth/device/authorize``); this redeems it. All
|
|
270
|
+
token minting therefore stays server-side in Jingwei's existing device
|
|
271
|
+
flow — no refresh token is ever handed to browser JavaScript, and the
|
|
272
|
+
user's own web session is left untouched rather than rotated out from
|
|
273
|
+
under them.
|
|
274
|
+
|
|
275
|
+
Returns ``None`` when the code is unknown, expired, or still pending.
|
|
276
|
+
"""
|
|
277
|
+
try:
|
|
278
|
+
async with httpx.AsyncClient(timeout=10, headers=client_headers("mcp")) as client:
|
|
279
|
+
resp = await client.post(
|
|
280
|
+
f"{self.api_url}/auth/device/token",
|
|
281
|
+
json={"device_code": device_code},
|
|
282
|
+
)
|
|
283
|
+
except httpx.HTTPError as exc:
|
|
284
|
+
logger.error("Jingwei device-token request failed: %s", exc)
|
|
285
|
+
return None
|
|
286
|
+
|
|
287
|
+
if resp.status_code != 200:
|
|
288
|
+
logger.warning(
|
|
289
|
+
"Device-code redemption rejected (HTTP %s)", resp.status_code
|
|
290
|
+
)
|
|
291
|
+
return None
|
|
292
|
+
|
|
293
|
+
data = resp.json()
|
|
294
|
+
# A code that was never approved comes back 200 with {"status":
|
|
295
|
+
# "pending"} rather than an error, so this is a real branch, not
|
|
296
|
+
# defensive padding.
|
|
297
|
+
if data.get("status") == "pending":
|
|
298
|
+
return None
|
|
299
|
+
|
|
300
|
+
user_id = data.get("user", {}).get("id")
|
|
301
|
+
refresh_token = data.get("refresh_token")
|
|
302
|
+
if not user_id or not refresh_token:
|
|
303
|
+
logger.error("Device-token response missing user id or refresh token")
|
|
304
|
+
return None
|
|
305
|
+
return user_id, refresh_token
|
|
306
|
+
|
|
307
|
+
# ── Authorization code lifecycle ─────────────────────────
|
|
308
|
+
|
|
309
|
+
def create_authorization_code(
|
|
310
|
+
self,
|
|
311
|
+
session_id: str,
|
|
312
|
+
user_id: str,
|
|
313
|
+
jingwei_refresh_token: str,
|
|
314
|
+
) -> tuple[str, str, str | None]:
|
|
315
|
+
"""Called by the login callback after successful user authentication.
|
|
316
|
+
|
|
317
|
+
``jingwei_refresh_token`` is the refresh token Jingwei issued during
|
|
318
|
+
login; it is carried on the (single-use, short-lived) auth code so that
|
|
319
|
+
``exchange_authorization_code`` can rotate it into the tokens handed to
|
|
320
|
+
the MCP client.
|
|
321
|
+
|
|
322
|
+
Returns ``(code, redirect_uri, state)`` so the caller can redirect.
|
|
323
|
+
"""
|
|
324
|
+
pending = self._pending_auth.pop(session_id, None)
|
|
325
|
+
if pending is None:
|
|
326
|
+
raise ValueError("Invalid or expired session")
|
|
327
|
+
|
|
328
|
+
client, params, created_at = pending
|
|
329
|
+
if time.time() - created_at > _PENDING_AUTH_TTL:
|
|
330
|
+
raise ValueError("Invalid or expired session")
|
|
331
|
+
code = secrets.token_urlsafe(20) # ≥160 bits entropy
|
|
332
|
+
|
|
333
|
+
self._auth_codes[code] = DeepCellAuthorizationCode(
|
|
334
|
+
code=code,
|
|
335
|
+
scopes=params.scopes or [],
|
|
336
|
+
expires_at=time.time() + _AUTH_CODE_TTL,
|
|
337
|
+
client_id=client.client_id or "",
|
|
338
|
+
code_challenge=params.code_challenge,
|
|
339
|
+
redirect_uri=params.redirect_uri,
|
|
340
|
+
redirect_uri_provided_explicitly=params.redirect_uri_provided_explicitly,
|
|
341
|
+
resource=params.resource,
|
|
342
|
+
user_id=user_id,
|
|
343
|
+
jingwei_refresh_token=jingwei_refresh_token,
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
return code, str(params.redirect_uri), params.state
|
|
347
|
+
|
|
348
|
+
async def load_authorization_code(
|
|
349
|
+
self,
|
|
350
|
+
client: OAuthClientInformationFull,
|
|
351
|
+
authorization_code: str,
|
|
352
|
+
) -> DeepCellAuthorizationCode | None:
|
|
353
|
+
ac = self._auth_codes.get(authorization_code)
|
|
354
|
+
if ac is None:
|
|
355
|
+
return None
|
|
356
|
+
if time.time() > ac.expires_at:
|
|
357
|
+
self._auth_codes.pop(authorization_code, None)
|
|
358
|
+
return None
|
|
359
|
+
if ac.client_id != client.client_id:
|
|
360
|
+
return None
|
|
361
|
+
return ac
|
|
362
|
+
|
|
363
|
+
async def exchange_authorization_code(
|
|
364
|
+
self,
|
|
365
|
+
client: OAuthClientInformationFull,
|
|
366
|
+
authorization_code: DeepCellAuthorizationCode,
|
|
367
|
+
) -> OAuthToken:
|
|
368
|
+
# Remove the code (single use)
|
|
369
|
+
self._auth_codes.pop(authorization_code.code, None)
|
|
370
|
+
|
|
371
|
+
# Rotate the login-issued Jingwei refresh token into the tokens we hand
|
|
372
|
+
# the MCP client. This immediately consumes the login token (so the copy
|
|
373
|
+
# stashed on the auth code can't be replayed) and returns a fresh,
|
|
374
|
+
# Postgres-backed access/refresh pair.
|
|
375
|
+
access_token, refresh_token, expires_in = await self._jingwei_refresh(
|
|
376
|
+
authorization_code.jingwei_refresh_token
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
scopes = authorization_code.scopes
|
|
380
|
+
return OAuthToken(
|
|
381
|
+
access_token=access_token,
|
|
382
|
+
token_type="Bearer",
|
|
383
|
+
expires_in=expires_in,
|
|
384
|
+
scope=" ".join(scopes) if scopes else None,
|
|
385
|
+
refresh_token=refresh_token,
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
# ── Token verification ───────────────────────────────────
|
|
389
|
+
|
|
390
|
+
async def load_access_token(self, token: str) -> DeepCellAccessToken | None:
|
|
391
|
+
# API key path — verify via Jingwei API
|
|
392
|
+
if token.startswith("dck_"):
|
|
393
|
+
return await self._verify_api_key(token)
|
|
394
|
+
|
|
395
|
+
# JWT path
|
|
396
|
+
if not self.jwt_secret:
|
|
397
|
+
return None
|
|
398
|
+
try:
|
|
399
|
+
payload = pyjwt.decode(
|
|
400
|
+
token,
|
|
401
|
+
self.jwt_secret,
|
|
402
|
+
algorithms=[self.jwt_algorithm],
|
|
403
|
+
issuer=self.jwt_issuer,
|
|
404
|
+
audience=self.jwt_audience,
|
|
405
|
+
)
|
|
406
|
+
if payload.get("type") != "access":
|
|
407
|
+
return None
|
|
408
|
+
return DeepCellAccessToken(
|
|
409
|
+
token=token,
|
|
410
|
+
client_id="", # Not tracked per-token
|
|
411
|
+
scopes=["deepcell"],
|
|
412
|
+
expires_at=payload.get("exp"),
|
|
413
|
+
user_id=payload["sub"],
|
|
414
|
+
)
|
|
415
|
+
except (pyjwt.ExpiredSignatureError, pyjwt.InvalidTokenError):
|
|
416
|
+
return None
|
|
417
|
+
|
|
418
|
+
async def _verify_api_key(self, token: str) -> DeepCellAccessToken | None:
|
|
419
|
+
"""Verify a ``dck_`` API key via the Jingwei ``/auth/api-keys/verify``
|
|
420
|
+
endpoint. Results are cached in-memory for 60 seconds."""
|
|
421
|
+
import hashlib
|
|
422
|
+
import os
|
|
423
|
+
|
|
424
|
+
cache_key = hashlib.sha256(token.encode()).hexdigest()
|
|
425
|
+
now = time.time()
|
|
426
|
+
|
|
427
|
+
# Check cache
|
|
428
|
+
cached = self._api_key_cache.get(cache_key)
|
|
429
|
+
if cached and now - cached[1] < 60:
|
|
430
|
+
return cached[0]
|
|
431
|
+
|
|
432
|
+
# Call Jingwei API
|
|
433
|
+
jingwei_api_key = os.environ.get("JINGWEI_API_KEY", "")
|
|
434
|
+
headers: dict[str, str] = {}
|
|
435
|
+
if jingwei_api_key:
|
|
436
|
+
headers["Authorization"] = f"Bearer {jingwei_api_key}"
|
|
437
|
+
|
|
438
|
+
try:
|
|
439
|
+
async with httpx.AsyncClient(timeout=10, headers=client_headers("mcp")) as client:
|
|
440
|
+
resp = await client.post(
|
|
441
|
+
f"{self.api_url}/auth/api-keys/verify",
|
|
442
|
+
json={"key": token},
|
|
443
|
+
headers=headers,
|
|
444
|
+
)
|
|
445
|
+
if resp.status_code == 200:
|
|
446
|
+
data = resp.json()
|
|
447
|
+
result = DeepCellAccessToken(
|
|
448
|
+
token=token,
|
|
449
|
+
client_id="api-key",
|
|
450
|
+
scopes=data.get("scopes", ["deepcell"]),
|
|
451
|
+
expires_at=None,
|
|
452
|
+
user_id=data["user_id"],
|
|
453
|
+
)
|
|
454
|
+
self._api_key_cache[cache_key] = (result, now)
|
|
455
|
+
return result
|
|
456
|
+
except httpx.HTTPError as exc:
|
|
457
|
+
logger.error("API key verification request failed: %s", exc)
|
|
458
|
+
|
|
459
|
+
return None
|
|
460
|
+
|
|
461
|
+
# ── Refresh token lifecycle ──────────────────────────────
|
|
462
|
+
|
|
463
|
+
async def load_refresh_token(
|
|
464
|
+
self,
|
|
465
|
+
client: OAuthClientInformationFull,
|
|
466
|
+
refresh_token: str,
|
|
467
|
+
) -> DeepCellRefreshToken | None:
|
|
468
|
+
# There is no local refresh-token store — the token is a Jingwei refresh
|
|
469
|
+
# token, validated server-side at rotation time (see _jingwei_refresh).
|
|
470
|
+
# Return an optimistic wrapper bound to the requesting client so the SDK
|
|
471
|
+
# token handler's pre-exchange checks (client_id match, expiry, scope
|
|
472
|
+
# subset) pass; Jingwei is the real authority on validity.
|
|
473
|
+
return DeepCellRefreshToken(
|
|
474
|
+
token=refresh_token,
|
|
475
|
+
client_id=client.client_id or "",
|
|
476
|
+
scopes=["deepcell"],
|
|
477
|
+
expires_at=None,
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
async def exchange_refresh_token(
|
|
481
|
+
self,
|
|
482
|
+
client: OAuthClientInformationFull,
|
|
483
|
+
refresh_token: DeepCellRefreshToken,
|
|
484
|
+
scopes: list[str],
|
|
485
|
+
) -> OAuthToken:
|
|
486
|
+
# Delegate rotation to Jingwei (Postgres-backed, with token-family
|
|
487
|
+
# reuse-detection). Raises TokenError(invalid_grant) if the token is
|
|
488
|
+
# rejected, or TransientAuthError if the backend is unreachable.
|
|
489
|
+
access_token, new_refresh, expires_in = await self._jingwei_refresh(
|
|
490
|
+
refresh_token.token
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
return OAuthToken(
|
|
494
|
+
access_token=access_token,
|
|
495
|
+
token_type="Bearer",
|
|
496
|
+
expires_in=expires_in,
|
|
497
|
+
scope=" ".join(scopes) if scopes else None,
|
|
498
|
+
refresh_token=new_refresh,
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
# ── Revocation ───────────────────────────────────────────
|
|
502
|
+
|
|
503
|
+
async def revoke_token(
|
|
504
|
+
self,
|
|
505
|
+
token: DeepCellAccessToken | DeepCellRefreshToken,
|
|
506
|
+
) -> None:
|
|
507
|
+
# A refresh token *is* a Jingwei refresh token, so an explicit OAuth
|
|
508
|
+
# revocation (e.g. a connector being disconnected) is delegated to
|
|
509
|
+
# Jingwei's ``/auth/revoke-token`` endpoint, which invalidates the
|
|
510
|
+
# underlying session in Postgres. Best-effort: per RFC 7009 the client's
|
|
511
|
+
# revoke request should succeed regardless, so transport/HTTP failures
|
|
512
|
+
# are logged and swallowed rather than surfaced. Access tokens are
|
|
513
|
+
# stateless short-lived JWTs that cannot be revoked without a blocklist,
|
|
514
|
+
# so they simply expire (15 min).
|
|
515
|
+
if isinstance(token, DeepCellRefreshToken):
|
|
516
|
+
try:
|
|
517
|
+
async with httpx.AsyncClient(timeout=10, headers=client_headers("mcp")) as client:
|
|
518
|
+
await client.post(
|
|
519
|
+
f"{self.api_url}/auth/revoke-token",
|
|
520
|
+
json={"refresh_token": token.token},
|
|
521
|
+
)
|
|
522
|
+
except httpx.HTTPError as exc:
|
|
523
|
+
logger.warning("Jingwei revoke request failed (best-effort): %s", exc)
|
|
524
|
+
return None
|
|
525
|
+
|
|
526
|
+
# ── Internal helpers ─────────────────────────────────────
|
|
527
|
+
|
|
528
|
+
async def _jingwei_refresh(self, raw_refresh_token: str) -> tuple[str, str, int]:
|
|
529
|
+
"""Rotate a Jingwei refresh token via the API's ``/auth/refresh`` endpoint.
|
|
530
|
+
|
|
531
|
+
An MCP refresh token *is* a Jingwei refresh token, so rotation,
|
|
532
|
+
token-family reuse-detection and revocation all live in Postgres and
|
|
533
|
+
survive container recreates. ``/auth/refresh`` accepts the token in the
|
|
534
|
+
request body (``RefreshRequest.refresh_token``).
|
|
535
|
+
|
|
536
|
+
Returns ``(access_token, refresh_token, expires_in)``.
|
|
537
|
+
|
|
538
|
+
Error mapping distinguishes a *bad grant* from a *transient outage* so a
|
|
539
|
+
backend blip doesn't log clients out (see ``TransientAuthError``):
|
|
540
|
+
|
|
541
|
+
Raises:
|
|
542
|
+
TokenError(``invalid_grant``): Jingwei rejected the token with a 4xx
|
|
543
|
+
(invalid / expired / reused) — the grant is genuinely bad, so the
|
|
544
|
+
client should re-authorize.
|
|
545
|
+
TransientAuthError: the backend was unreachable, timed out, returned
|
|
546
|
+
a 5xx, or returned 200 without tokens (a contract violation). The
|
|
547
|
+
grant may still be valid, so the client should retry and keep it.
|
|
548
|
+
"""
|
|
549
|
+
try:
|
|
550
|
+
async with httpx.AsyncClient(timeout=10, headers=client_headers("mcp")) as client:
|
|
551
|
+
resp = await client.post(
|
|
552
|
+
f"{self.api_url}/auth/refresh",
|
|
553
|
+
json={"refresh_token": raw_refresh_token},
|
|
554
|
+
)
|
|
555
|
+
except httpx.HTTPError as exc:
|
|
556
|
+
logger.error("Jingwei refresh request failed (transient): %s", exc)
|
|
557
|
+
raise TransientAuthError("Auth service unreachable") from exc
|
|
558
|
+
|
|
559
|
+
if resp.status_code == 200:
|
|
560
|
+
data = resp.json()
|
|
561
|
+
access_token = data.get("access_token")
|
|
562
|
+
new_refresh = data.get("refresh_token")
|
|
563
|
+
if not access_token or not new_refresh:
|
|
564
|
+
# 200 but no tokens is a backend bug, not a rejected grant —
|
|
565
|
+
# don't force the client to re-auth over it.
|
|
566
|
+
logger.error("Jingwei refresh response missing tokens")
|
|
567
|
+
raise TransientAuthError("Auth service returned no tokens")
|
|
568
|
+
return access_token, new_refresh, int(data.get("expires_in", 900))
|
|
569
|
+
|
|
570
|
+
if 400 <= resp.status_code < 500:
|
|
571
|
+
logger.info("Jingwei rejected refresh token (status %s)", resp.status_code)
|
|
572
|
+
raise TokenError(
|
|
573
|
+
error="invalid_grant",
|
|
574
|
+
error_description="Refresh token is invalid or expired",
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
# 5xx (and anything else non-2xx): transient backend failure.
|
|
578
|
+
logger.error("Jingwei refresh failed transiently (status %s)", resp.status_code)
|
|
579
|
+
raise TransientAuthError(f"Auth service error (status {resp.status_code})")
|
|
580
|
+
|