boxadm-mcp 0.3.2__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.
boxadm_mcp/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """boxadm-mcp — Box admin-log analytics MCP (read-only external-sharing visibility)."""
2
+
3
+ __version__ = "0.3.2" # x-release-please-version
boxadm_mcp/__main__.py ADDED
@@ -0,0 +1,49 @@
1
+ """Entry point: `boxadm-mcp` (stdio server) / `boxadm-mcp auth` / `boxadm-mcp --version`."""
2
+
3
+ import asyncio
4
+ import os
5
+ import sys
6
+
7
+ from boxadm_mcp import __version__
8
+
9
+
10
+ def _auth() -> None:
11
+ """One-time OAuth login: writes the token cache for BoxOAuthClient."""
12
+ from boxadm_mcp.oauth import DEFAULT_REDIRECT_URI, login
13
+
14
+ cache = login(
15
+ os.environ["BOX_CLIENT_ID"],
16
+ os.environ["BOX_CLIENT_SECRET"],
17
+ redirect_uri=os.environ.get("BOX_OAUTH_REDIRECT_URI", DEFAULT_REDIRECT_URI),
18
+ token_cache=os.environ.get("BOX_TOKEN_CACHE") or None,
19
+ )
20
+ print(f"token cache written: {cache}")
21
+
22
+
23
+ def main() -> None:
24
+ argv = sys.argv[1:]
25
+ if "--version" in argv:
26
+ print(f"boxadm-mcp {__version__}")
27
+ return
28
+ if argv and argv[0] == "auth":
29
+ _auth()
30
+ return
31
+ try:
32
+ # Import lazily so `--version` / `auth` work without the MCP runtime.
33
+ # The import sits inside the try so a ^C during the (slow) import chain
34
+ # also exits cleanly, not just one delivered while the server runs.
35
+ from boxadm_mcp.server import mcp
36
+
37
+ mcp.run()
38
+ except (KeyboardInterrupt, asyncio.CancelledError):
39
+ # anyio's teardown on SIGINT dumps a 20-80 line traceback. What it
40
+ # raises out of mcp.run() is Python-version-dependent: a bare
41
+ # KeyboardInterrupt on 3.12/3.13, but asyncio.CancelledError on 3.10
42
+ # (asyncio.Runner.run() re-raises CancelledError instead of letting
43
+ # KeyboardInterrupt propagate). Catch both and exit clean, same
44
+ # convention as the sibling fleet MCP servers.
45
+ os._exit(0)
46
+
47
+
48
+ if __name__ == "__main__":
49
+ main()
boxadm_mcp/client.py ADDED
@@ -0,0 +1,470 @@
1
+ """Box Enterprise API clients (read-only).
2
+
3
+ Two auth modes, same read-only surface (`authenticate()` + `get_admin_events()`):
4
+
5
+ - ``BoxClient`` — Client Credentials Grant (server-to-server, enterprise subject).
6
+ - ``BoxOAuthClient`` — OAuth 2.0 (user auth) with a cached, auto-refreshed token.
7
+ A one-time interactive login (``boxadm-mcp auth``) writes the token cache; the
8
+ client refreshes the access token from the stored refresh token as needed and
9
+ persists the rotated refresh token (Box rotates it on every refresh).
10
+
11
+ Read-only by design: neither client issues a mutating call. boxadm-mcp surfaces
12
+ risk; it never enforces.
13
+ """
14
+
15
+ import fcntl
16
+ import json
17
+ import os
18
+ import time
19
+ from contextlib import contextmanager
20
+ from pathlib import Path
21
+
22
+ import httpx
23
+
24
+ DEFAULT_TIMEOUT = 30
25
+ DEFAULT_API_BASE = "https://api.box.com"
26
+ TOKEN_PATH = "/oauth2/token"
27
+ API_PREFIX = "/2.0"
28
+
29
+ # Refresh a token this many seconds before its stated expiry, so a call never
30
+ # races a just-expired token.
31
+ TOKEN_REFRESH_SKEW = 60
32
+
33
+
34
+ class BoxError(Exception):
35
+ """Base error for Box API failures."""
36
+
37
+
38
+ class BoxAuthError(BoxError):
39
+ """Raised when authentication fails (bad creds / app not authorized)."""
40
+
41
+
42
+ class BoxNotAuthenticatedError(BoxAuthError):
43
+ """No usable OAuth token cache — run ``boxadm-mcp auth`` to log in."""
44
+
45
+
46
+ def default_token_cache() -> str:
47
+ """Default OAuth token cache path (override via BOX_TOKEN_CACHE)."""
48
+ return os.path.expanduser("~/.config/boxadm-mcp/token.json")
49
+
50
+
51
+ @contextmanager
52
+ def cache_lock(path: str):
53
+ """Exclusive cross-process lock guarding token-cache read/refresh/write.
54
+
55
+ Box refresh tokens are single-use and rotate on every refresh; two processes
56
+ sharing one cache file must never refresh concurrently (Box treats reuse of a
57
+ rotated token as compromise and revokes the whole chain). The lock is a
58
+ persistent sidecar ``<cache>.lock`` file rather than the cache itself, because
59
+ ``write_token_cache`` replaces the cache inode (``os.replace``) — a lock held
60
+ on a replaced inode would no longer exclude the next locker.
61
+ """
62
+ lock_path = path + ".lock"
63
+ try:
64
+ Path(lock_path).parent.mkdir(parents=True, exist_ok=True)
65
+ fd = os.open(lock_path, os.O_WRONLY | os.O_CREAT, 0o600)
66
+ except OSError as e:
67
+ # Wrap into the Box exception hierarchy so callers (health_check, tool
68
+ # handlers) keep their "structured error, never a raw traceback" contract.
69
+ # Typical trigger: the sidecar was created by the wrong user (e.g. `auth`
70
+ # run without sudo -u mcp) — fix ownership or remove the .lock file.
71
+ raise BoxAuthError(f"token cache lock unavailable: {lock_path}: {e}") from e
72
+ try:
73
+ try:
74
+ fcntl.flock(fd, fcntl.LOCK_EX)
75
+ except OSError as e:
76
+ raise BoxAuthError(f"token cache lock failed: {lock_path}: {e}") from e
77
+ yield
78
+ finally:
79
+ os.close(fd) # closing the fd releases the flock
80
+
81
+
82
+ def write_token_cache(path: str, data: dict) -> None:
83
+ """Write the token cache atomically and owner-only (0600).
84
+
85
+ Created at 0600 from the start (no world-readable window), written to a temp
86
+ file then ``os.replace``d in, so a crash mid-write can't corrupt the cache and
87
+ trigger a spurious re-login.
88
+ """
89
+ p = Path(path)
90
+ p.parent.mkdir(parents=True, exist_ok=True)
91
+ tmp = p.with_name(p.name + ".tmp")
92
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
93
+ try:
94
+ with os.fdopen(fd, "w") as f:
95
+ json.dump(data, f)
96
+ except BaseException:
97
+ try:
98
+ os.unlink(tmp)
99
+ except OSError:
100
+ pass
101
+ raise
102
+ os.replace(tmp, p) # atomic; the 0600 temp inode becomes the cache
103
+
104
+
105
+ def save_token_cache(path: str, access: str, refresh: str, expires_in: int) -> None:
106
+ """Persist a token-endpoint result. Single source of truth for the cache schema."""
107
+ write_token_cache(
108
+ path,
109
+ {"access_token": access, "refresh_token": refresh, "access_expires_at": int(time.time()) + int(expires_in)},
110
+ )
111
+
112
+
113
+ def _events_params(
114
+ created_after: str | None,
115
+ created_before: str | None,
116
+ event_types: list[str] | None,
117
+ stream_position: str | int | None,
118
+ limit: int,
119
+ ) -> dict:
120
+ """Build query params for an enterprise ``admin_logs`` events page."""
121
+ params: dict = {"stream_type": "admin_logs", "limit": limit}
122
+ if created_after:
123
+ params["created_after"] = created_after
124
+ if created_before:
125
+ params["created_before"] = created_before
126
+ if event_types:
127
+ params["event_type"] = ",".join(event_types)
128
+ if stream_position is not None:
129
+ params["stream_position"] = stream_position
130
+ return params
131
+
132
+
133
+ def fetch_admin_events(
134
+ client,
135
+ *,
136
+ created_after: str | None = None,
137
+ created_before: str | None = None,
138
+ event_types: list[str] | None = None,
139
+ max_events: int = 5000,
140
+ page_size: int = 500,
141
+ created_by_logins: list[str] | None = None,
142
+ ) -> tuple[list[dict], bool]:
143
+ """Page through ``admin_logs`` and collect events from the stream.
144
+
145
+ Works with either client (both expose ``get_admin_events``). Returns
146
+ ``(events, capped)`` where ``capped`` is True when the ``max_events`` cap was
147
+ hit before the stream was exhausted — so callers can disclose truncation
148
+ rather than silently under-report.
149
+
150
+ ``max_events`` bounds the number of events *scanned* from the stream (the
151
+ volume-safety limit), not the number kept. When ``created_by_logins`` is set,
152
+ only events whose ``created_by.login`` is in that set are kept (a client-side
153
+ filter — Box admin_logs has no ``created_by`` query param), so a specific
154
+ accessor can be traced across the whole window while API work stays bounded.
155
+ With no filter, kept == scanned and the behaviour is unchanged.
156
+ """
157
+ logins = set(created_by_logins) if created_by_logins else None
158
+ out: list[dict] = []
159
+ pos: str | int | None = None
160
+ scanned = 0
161
+ capped = False
162
+ while True:
163
+ if scanned >= max_events:
164
+ capped = True
165
+ break
166
+ resp = client.get_admin_events(
167
+ created_after=created_after,
168
+ created_before=created_before,
169
+ event_types=event_types,
170
+ stream_position=pos,
171
+ limit=min(page_size, max_events - scanned),
172
+ )
173
+ entries = resp.get("entries", [])
174
+ scanned += len(entries)
175
+ if logins is None:
176
+ out.extend(entries)
177
+ else:
178
+ out.extend(e for e in entries if ((e.get("created_by") or {}).get("login")) in logins)
179
+ nxt = resp.get("next_stream_position")
180
+ if not entries or nxt is None or str(nxt) == str(pos):
181
+ break
182
+ pos = nxt
183
+ return out[:max_events], capped
184
+
185
+
186
+ class _FolderReadMixin:
187
+ """Read-only folder / collaboration / shared-link getters shared by both clients.
188
+
189
+ Relies on the subclass providing ``_get(path, params)``.
190
+ """
191
+
192
+ def get_folder(self, folder_id: str, *, fields: list[str] | None = None) -> dict:
193
+ return self._get(f"/folders/{folder_id}", {"fields": ",".join(fields)} if fields else None)
194
+
195
+ def get_folder_items(self, folder_id: str, *, fields: list[str] | None = None, limit: int = 1000, offset: int = 0) -> dict:
196
+ params: dict = {"limit": limit, "offset": offset}
197
+ if fields:
198
+ params["fields"] = ",".join(fields)
199
+ return self._get(f"/folders/{folder_id}/items", params)
200
+
201
+ def get_folder_collaborations(self, folder_id: str) -> dict:
202
+ return self._get(f"/folders/{folder_id}/collaborations")
203
+
204
+
205
+ class BoxClient(_FolderReadMixin):
206
+ """Read-only Box enterprise client using the CCG flow."""
207
+
208
+ def __init__(
209
+ self,
210
+ client_id: str,
211
+ client_secret: str,
212
+ enterprise_id: str,
213
+ *,
214
+ api_base: str = DEFAULT_API_BASE,
215
+ timeout: int = DEFAULT_TIMEOUT,
216
+ ):
217
+ self._client_id = client_id
218
+ self._client_secret = client_secret
219
+ self._enterprise_id = enterprise_id
220
+ self._base = api_base.rstrip("/")
221
+ self._http = httpx.Client(timeout=timeout)
222
+ self._token: str | None = None
223
+ self._token_deadline = 0.0 # monotonic clock deadline
224
+
225
+ def _ensure_token(self) -> str:
226
+ if self._token and time.monotonic() < self._token_deadline:
227
+ return self._token
228
+ data = {
229
+ "grant_type": "client_credentials",
230
+ "client_id": self._client_id,
231
+ "client_secret": self._client_secret,
232
+ "box_subject_type": "enterprise",
233
+ "box_subject_id": self._enterprise_id,
234
+ }
235
+ try:
236
+ resp = self._http.post(self._base + TOKEN_PATH, data=data)
237
+ resp.raise_for_status()
238
+ body = resp.json()
239
+ except httpx.HTTPStatusError as e:
240
+ raise BoxAuthError(f"token request failed: HTTP {e.response.status_code}") from e
241
+ except httpx.HTTPError as e:
242
+ raise BoxAuthError(f"token request error: {e}") from e
243
+ try:
244
+ self._token = body["access_token"]
245
+ except (KeyError, TypeError) as e:
246
+ raise BoxAuthError("token response missing access_token") from e
247
+ self._token_deadline = time.monotonic() + max(0, int(body.get("expires_in", 3600)) - TOKEN_REFRESH_SKEW)
248
+ return self._token
249
+
250
+ def authenticate(self) -> bool:
251
+ """Obtain (or refresh) the CCG token. Returns True on success, else raises."""
252
+ self._ensure_token()
253
+ return True
254
+
255
+ def _get(self, path: str, params: dict | None = None, *, _retry_auth: bool = True) -> dict:
256
+ token = self._ensure_token()
257
+ try:
258
+ resp = self._http.get(
259
+ self._base + API_PREFIX + path,
260
+ params=params,
261
+ headers={"Authorization": f"Bearer {token}"},
262
+ )
263
+ resp.raise_for_status()
264
+ return resp.json()
265
+ except httpx.HTTPStatusError as e:
266
+ if e.response.status_code == 401 and _retry_auth:
267
+ self._token = None
268
+ self._token_deadline = 0.0
269
+ return self._get(path, params, _retry_auth=False)
270
+ raise BoxError(f"HTTP {e.response.status_code}: GET {path}") from e
271
+ except httpx.HTTPError as e:
272
+ raise BoxError(f"connection error: GET {path}: {e}") from e
273
+
274
+ @property
275
+ def enterprise_id(self) -> str:
276
+ return self._enterprise_id
277
+
278
+ def get_admin_events(
279
+ self,
280
+ *,
281
+ created_after: str | None = None,
282
+ created_before: str | None = None,
283
+ event_types: list[str] | None = None,
284
+ stream_position: str | int | None = None,
285
+ limit: int = 500,
286
+ ) -> dict:
287
+ """Fetch one page of enterprise ``admin_logs`` events (raw Box response)."""
288
+ return self._get("/events", _events_params(created_after, created_before, event_types, stream_position, limit))
289
+
290
+ def close(self) -> None:
291
+ self._http.close()
292
+
293
+ def __enter__(self) -> "BoxClient":
294
+ return self
295
+
296
+ def __exit__(self, *exc) -> None:
297
+ self.close()
298
+
299
+
300
+ class BoxOAuthClient(_FolderReadMixin):
301
+ """Read-only Box client using OAuth 2.0 (user auth) with an auto-refreshed token cache."""
302
+
303
+ def __init__(
304
+ self,
305
+ client_id: str,
306
+ client_secret: str,
307
+ *,
308
+ token_cache: str | None = None,
309
+ api_base: str = DEFAULT_API_BASE,
310
+ timeout: int = DEFAULT_TIMEOUT,
311
+ ):
312
+ self._client_id = client_id
313
+ self._client_secret = client_secret
314
+ self._cache_path = token_cache or default_token_cache()
315
+ self._base = api_base.rstrip("/")
316
+ self._http = httpx.Client(timeout=timeout)
317
+ self._access: str | None = None
318
+ self._access_deadline = 0.0 # unix time (persisted across restarts)
319
+
320
+ # ------------------------------------------------------------------
321
+ # Token cache
322
+ # ------------------------------------------------------------------
323
+ def _load_cache(self) -> dict:
324
+ try:
325
+ with open(self._cache_path) as f:
326
+ return json.load(f)
327
+ except (OSError, ValueError):
328
+ return {}
329
+
330
+ def _save_cache(self, access: str, refresh: str, expires_in: int) -> None:
331
+ save_token_cache(self._cache_path, access, refresh, expires_in)
332
+
333
+ # ------------------------------------------------------------------
334
+ # Auth (refresh-token grant)
335
+ # ------------------------------------------------------------------
336
+ def _adopt_valid_access(self, cache: dict) -> str | None:
337
+ """Adopt the cached access token if it is still comfortably valid."""
338
+ access = cache.get("access_token")
339
+ expires_at = cache.get("access_expires_at", 0)
340
+ if access and time.time() < expires_at - TOKEN_REFRESH_SKEW:
341
+ self._access, self._access_deadline = access, expires_at
342
+ return access
343
+ return None
344
+
345
+ def _ensure_token(self) -> str:
346
+ if self._access and time.time() < self._access_deadline - TOKEN_REFRESH_SKEW:
347
+ return self._access
348
+ # Unlocked disk fast path: right after another process refreshed, adopt
349
+ # its result without serializing every reader through the lock. A miss
350
+ # (or a torn read) just falls through to the locked slow path.
351
+ access = self._adopt_valid_access(self._load_cache())
352
+ if access:
353
+ return access
354
+ # Slow path: refresh under the cross-process lock. Another process may
355
+ # win the race and rotate the refresh token while we wait for the lock,
356
+ # so re-read the cache once inside it — if a fresh access token appeared,
357
+ # use it and skip our own refresh (refreshing with the pre-lock token
358
+ # would present an already-rotated token and revoke the chain).
359
+ with cache_lock(self._cache_path):
360
+ cache = self._load_cache()
361
+ access = self._adopt_valid_access(cache)
362
+ if access:
363
+ return access
364
+ refresh = cache.get("refresh_token")
365
+ if not refresh:
366
+ raise BoxNotAuthenticatedError("no token cache; run 'boxadm-mcp auth' to log in")
367
+ return self._refresh(refresh)
368
+
369
+ def _refresh(self, refresh_token: str) -> str:
370
+ # Caller must hold cache_lock(self._cache_path): the refresh token is
371
+ # single-use and the rotated result is persisted here.
372
+ data = {
373
+ "grant_type": "refresh_token",
374
+ "refresh_token": refresh_token,
375
+ "client_id": self._client_id,
376
+ "client_secret": self._client_secret,
377
+ }
378
+ try:
379
+ resp = self._http.post(self._base + TOKEN_PATH, data=data)
380
+ resp.raise_for_status()
381
+ body = resp.json()
382
+ except httpx.HTTPStatusError as e:
383
+ status = e.response.status_code
384
+ if 400 <= status < 500 and status != 429:
385
+ # Refresh tokens last ~60 days and are single-use; a 4xx here means
386
+ # the chain is broken — re-login is required.
387
+ raise BoxNotAuthenticatedError(f"token refresh failed (HTTP {status}); run 'boxadm-mcp auth'") from e
388
+ # 5xx/429 is a transient server-side failure: the refresh token was
389
+ # not consumed, so the next call can retry with the same token —
390
+ # do NOT steer the operator toward an unnecessary re-login.
391
+ raise BoxAuthError(f"token refresh failed (HTTP {status}); transient — retry later") from e
392
+ except httpx.HTTPError as e:
393
+ # Residual risk with no client-side fix: if Box committed the rotation
394
+ # but the response was lost (timeout/disconnect), the cached refresh
395
+ # token is already consumed and the next retry trips Box's reuse
396
+ # detection (manual re-auth). The lock cannot prevent this class.
397
+ raise BoxAuthError(f"token refresh error: {e}") from e
398
+ try:
399
+ access = body["access_token"]
400
+ except (KeyError, TypeError) as e:
401
+ raise BoxAuthError("token response missing access_token") from e
402
+ new_refresh = body.get("refresh_token", refresh_token) # Box rotates it
403
+ expires_in = int(body.get("expires_in", 3600))
404
+ self._save_cache(access, new_refresh, expires_in)
405
+ self._access, self._access_deadline = access, int(time.time()) + expires_in
406
+ return access
407
+
408
+ def _force_refresh(self) -> None:
409
+ rejected = self._access
410
+ with cache_lock(self._cache_path):
411
+ # The token that just got a 401 may already have been replaced by
412
+ # another process; adopt the newer on-disk one instead of refreshing.
413
+ # If the adopted token is itself revoked (whole-chain revocation),
414
+ # the retried call fails once with a generic 401; the next call then
415
+ # takes the refresh path below and surfaces needs-login.
416
+ cache = self._load_cache()
417
+ access = cache.get("access_token")
418
+ expires_at = cache.get("access_expires_at", 0)
419
+ if access and access != rejected and time.time() < expires_at - TOKEN_REFRESH_SKEW:
420
+ self._access, self._access_deadline = access, expires_at
421
+ return
422
+ refresh = cache.get("refresh_token")
423
+ if not refresh:
424
+ raise BoxNotAuthenticatedError("no token cache; run 'boxadm-mcp auth' to log in")
425
+ self._refresh(refresh)
426
+
427
+ def authenticate(self) -> bool:
428
+ """Ensure a valid access token (refreshing if needed). Returns True or raises."""
429
+ self._ensure_token()
430
+ return True
431
+
432
+ def _get(self, path: str, params: dict | None = None, *, _retry_auth: bool = True) -> dict:
433
+ token = self._ensure_token()
434
+ try:
435
+ resp = self._http.get(
436
+ self._base + API_PREFIX + path,
437
+ params=params,
438
+ headers={"Authorization": f"Bearer {token}"},
439
+ )
440
+ resp.raise_for_status()
441
+ return resp.json()
442
+ except httpx.HTTPStatusError as e:
443
+ if e.response.status_code == 401 and _retry_auth:
444
+ # Access token revoked before its deadline — force a fresh one.
445
+ self._force_refresh()
446
+ return self._get(path, params, _retry_auth=False)
447
+ raise BoxError(f"HTTP {e.response.status_code}: GET {path}") from e
448
+ except httpx.HTTPError as e:
449
+ raise BoxError(f"connection error: GET {path}: {e}") from e
450
+
451
+ def get_admin_events(
452
+ self,
453
+ *,
454
+ created_after: str | None = None,
455
+ created_before: str | None = None,
456
+ event_types: list[str] | None = None,
457
+ stream_position: str | int | None = None,
458
+ limit: int = 500,
459
+ ) -> dict:
460
+ """Fetch one page of enterprise ``admin_logs`` events (raw Box response)."""
461
+ return self._get("/events", _events_params(created_after, created_before, event_types, stream_position, limit))
462
+
463
+ def close(self) -> None:
464
+ self._http.close()
465
+
466
+ def __enter__(self) -> "BoxOAuthClient":
467
+ return self
468
+
469
+ def __exit__(self, *exc) -> None:
470
+ self.close()
boxadm_mcp/config.py ADDED
@@ -0,0 +1,42 @@
1
+ """Configuration: organization domain allowlist and external-actor detection.
2
+
3
+ The whole point of boxadm-mcp is to surface *external* file flow, so the one piece
4
+ of org-specific knowledge it needs is "which email domains are us". Everything
5
+ else (who shares, what leaks) is derived from Box events relative to this list.
6
+
7
+ No organization-specific value is hardcoded: set ``BOX_ALLOWED_DOMAINS``
8
+ (comma-separated) to your own domains. Left unset, the allowlist is empty and
9
+ every address is treated as external — a safe default for a leakage-detection
10
+ tool (nothing is silently trusted as internal until you configure it).
11
+ """
12
+
13
+ import os
14
+
15
+ DEFAULT_ALLOWED_DOMAINS: tuple[str, ...] = ()
16
+
17
+
18
+ def allowed_domains() -> list[str]:
19
+ """Return the internal (org) email domains, lower-cased.
20
+
21
+ Reads BOX_ALLOWED_DOMAINS (comma-separated); empty/unset yields no domains,
22
+ so is_external() treats every address as external until configured.
23
+ """
24
+ raw = os.environ.get("BOX_ALLOWED_DOMAINS")
25
+ if not raw:
26
+ return list(DEFAULT_ALLOWED_DOMAINS)
27
+ return [d.strip().lower() for d in raw.split(",") if d.strip()]
28
+
29
+
30
+ def is_external(email: str | None, domains: list[str] | None = None) -> bool:
31
+ """True when an email address is outside the organization.
32
+
33
+ A blank/missing/malformed address is treated as external — an unknown actor
34
+ is the cautious assumption for a leakage check. Subdomains of an allowed
35
+ domain (e.g. ``sub.example.com`` when ``example.com`` is allowed) count as
36
+ internal.
37
+ """
38
+ if not email or "@" not in email:
39
+ return True
40
+ doms = domains if domains is not None else allowed_domains()
41
+ dom = email.rsplit("@", 1)[1].strip().lower()
42
+ return not any(dom == d or dom.endswith("." + d) for d in doms)
boxadm_mcp/oauth.py ADDED
@@ -0,0 +1,99 @@
1
+ """Interactive OAuth 2.0 login — writes the token cache used by BoxOAuthClient.
2
+
3
+ Run once via ``boxadm-mcp auth``: opens the Box authorize URL, captures the
4
+ redirect on the local callback, exchanges the code for tokens, and persists the
5
+ refresh token (chmod 600). After this, the MCP server refreshes unattended.
6
+ """
7
+
8
+ import http.server
9
+ import time
10
+ import urllib.parse
11
+ import webbrowser
12
+
13
+ import httpx
14
+
15
+ from boxadm_mcp.client import DEFAULT_API_BASE, TOKEN_PATH, cache_lock, default_token_cache, save_token_cache
16
+
17
+ AUTHORIZE_URL = "https://account.box.com/api/oauth2/authorize"
18
+ DEFAULT_REDIRECT_URI = "http://localhost:8787/callback"
19
+
20
+
21
+ def build_authorize_url(client_id: str, redirect_uri: str, state: str) -> str:
22
+ """Construct the Box OAuth 2.0 authorize URL (pure, for testing)."""
23
+ return (
24
+ AUTHORIZE_URL + "?" + urllib.parse.urlencode({"response_type": "code", "client_id": client_id, "redirect_uri": redirect_uri, "state": state})
25
+ )
26
+
27
+
28
+ def _write_cache(token_cache: str, body: dict) -> None:
29
+ # Take the same cross-process lock as BoxOAuthClient's refresh path, so an
30
+ # interactive re-login can't interleave with a concurrent refresh rotation.
31
+ with cache_lock(token_cache):
32
+ save_token_cache(token_cache, body["access_token"], body["refresh_token"], int(body.get("expires_in", 3600)))
33
+
34
+
35
+ def login(
36
+ client_id: str,
37
+ client_secret: str,
38
+ *,
39
+ redirect_uri: str = DEFAULT_REDIRECT_URI,
40
+ token_cache: str | None = None,
41
+ api_base: str = DEFAULT_API_BASE,
42
+ open_browser: bool = True,
43
+ ) -> str:
44
+ """Run the authorization-code flow and write the token cache. Returns its path."""
45
+ token_cache = token_cache or default_token_cache()
46
+ state = "boxadm-" + str(int(time.time()))
47
+ authorize = build_authorize_url(client_id, redirect_uri, state)
48
+
49
+ parsed = urllib.parse.urlparse(redirect_uri)
50
+ host, port, cb_path = parsed.hostname or "localhost", parsed.port or 80, parsed.path or "/"
51
+ captured: dict = {}
52
+
53
+ class Handler(http.server.BaseHTTPRequestHandler):
54
+ def do_GET(self):
55
+ q = urllib.parse.urlparse(self.path)
56
+ if q.path != cb_path:
57
+ self.send_response(404)
58
+ self.end_headers()
59
+ return
60
+ p = urllib.parse.parse_qs(q.query)
61
+ captured["code"] = p.get("code", [None])[0]
62
+ captured["error"] = p.get("error", [None])[0]
63
+ captured["state"] = p.get("state", [None])[0]
64
+ self.send_response(200)
65
+ self.send_header("Content-Type", "text/html; charset=utf-8")
66
+ self.end_headers()
67
+ self.wfile.write("認可完了。このタブを閉じてターミナルに戻ってください。".encode())
68
+
69
+ def log_message(self, *a):
70
+ pass
71
+
72
+ print("Open this URL in your browser to authorize:\n" + authorize, flush=True)
73
+ if open_browser:
74
+ try:
75
+ webbrowser.open(authorize)
76
+ except Exception:
77
+ pass
78
+
79
+ srv = http.server.HTTPServer((host, port), Handler)
80
+ srv.timeout = 600
81
+ while "code" not in captured and "error" not in captured:
82
+ srv.handle_request()
83
+
84
+ if captured.get("error") or not captured.get("code"):
85
+ raise RuntimeError(f"authorization failed: {captured.get('error')}")
86
+ if captured.get("state") != state:
87
+ raise RuntimeError("state mismatch — possible CSRF; aborting")
88
+
89
+ data = {
90
+ "grant_type": "authorization_code",
91
+ "code": captured["code"],
92
+ "client_id": client_id,
93
+ "client_secret": client_secret,
94
+ "redirect_uri": redirect_uri,
95
+ }
96
+ resp = httpx.post(api_base.rstrip("/") + TOKEN_PATH, data=data, timeout=30)
97
+ resp.raise_for_status()
98
+ _write_cache(token_cache, resp.json())
99
+ return token_cache