First slice of the Box automation work. Adds the OAuth round-trip and a
smoke-test endpoint, but no automation logic or watcher yet — those land in
PR2 and PR3.
- New `backend/box_client.py`: OAuth helpers (build_authorize_url, exchange_code_for_tokens, refresh_tokens, revoke_tokens), JWT-signed state for CSRF protection, get_box_user, get_valid_access_token (refreshes if expired and persists the rotated refresh token Box returns on every refresh), and a list_folder_items helper used by the smoke test.
- New `backend/box_tokens.py`: thread-safe JSON-backed per-user token store at backend/box_tokens.json (gitignored — refresh tokens grant long-lived Box access). Persists access_token, refresh_token, computed access_token_expires_at, and the connected Box identity (id / login / name).
- New endpoints in `backend/api_server.py`:
- `GET /auth/box/login` — auth-required, redirects the signed-in user to Box's authorize URL with a JWT-signed state.
- `GET /auth/box/callback` — verifies the state, exchanges the code, fetches /users/me, persists the tokens, and returns a small self-closing HTML page (closes the popup if opened from one).
- `GET /api/box/status` — auth-required, returns {connected, configured, box_user_login, …} for the current user.
- `POST /api/box/disconnect` — auth-required, best-effort revoke at Box and clear the local tokens.
- `GET /api/box/test_folder?folder_id=…` — auth-required smoke test that lists a Box folder using the user's stored tokens. Default folder_id is "0" (the user's All Files root). Used to prove the OAuth round-trip works end-to-end before PR3 wires the watcher.
- Box config in env (`BOX_CLIENT_ID` / `BOX_CLIENT_SECRET` / `BOX_REDIRECT_URI`) added to all four env files and both .env.template files (placeholders).
Box rotates refresh tokens — every successful refresh returns a new pair and invalidates the previous one. `get_valid_access_token()` always writes the new pair back via `box_tokens.save_tokens()`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
127 lines
4.1 KiB
Python
127 lines
4.1 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Per-user Box.com OAuth token storage.
|
|
|
|
Box rotates refresh tokens on every refresh — every successful refresh returns
|
|
a NEW refresh token and invalidates the previous one. Always write back the
|
|
new pair via save_tokens() after a refresh, or the user will be silently
|
|
disconnected the next time around.
|
|
|
|
File: backend/box_tokens.json (gitignored — holds long-lived refresh tokens
|
|
that grant access to the user's Box account).
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
from datetime import datetime, timedelta, timezone
|
|
from threading import Lock
|
|
|
|
_TOKENS_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'box_tokens.json')
|
|
_lock = Lock()
|
|
|
|
|
|
def _default():
|
|
return {'version': 1, 'users': {}}
|
|
|
|
|
|
def _load():
|
|
if not os.path.exists(_TOKENS_PATH):
|
|
return _default()
|
|
try:
|
|
with open(_TOKENS_PATH, 'r') as f:
|
|
data = json.load(f)
|
|
if 'users' not in data:
|
|
data = _default()
|
|
return data
|
|
except (json.JSONDecodeError, OSError):
|
|
return _default()
|
|
|
|
|
|
def _save(data):
|
|
tmp = _TOKENS_PATH + '.tmp'
|
|
with open(tmp, 'w') as f:
|
|
json.dump(data, f, indent=2)
|
|
os.replace(tmp, _TOKENS_PATH)
|
|
|
|
|
|
def _normalize(email):
|
|
return (email or '').strip().lower()
|
|
|
|
|
|
def get_tokens(email):
|
|
"""Return the stored token record for `email`, or None."""
|
|
email = _normalize(email)
|
|
if not email:
|
|
return None
|
|
with _lock:
|
|
return _load().get('users', {}).get(email)
|
|
|
|
|
|
def save_tokens(email, token_response, box_user=None):
|
|
"""
|
|
Persist a Box token response for `email`.
|
|
|
|
Args:
|
|
email: Oliver-side user email (the QC tool identity, not the Box login)
|
|
token_response: dict with at least access_token, refresh_token, expires_in (seconds)
|
|
box_user: optional dict with Box user info {id, login, name}
|
|
"""
|
|
email = _normalize(email)
|
|
if not email:
|
|
raise ValueError('email is required')
|
|
if not token_response.get('access_token') or not token_response.get('refresh_token'):
|
|
raise ValueError('token_response must include access_token and refresh_token')
|
|
|
|
expires_in = int(token_response.get('expires_in', 3600))
|
|
# Subtract a small skew so we refresh slightly before actual expiry.
|
|
expires_at = datetime.now(timezone.utc) + timedelta(seconds=max(expires_in - 60, 60))
|
|
|
|
with _lock:
|
|
data = _load()
|
|
prior = data.get('users', {}).get(email, {})
|
|
record = {
|
|
'access_token': token_response['access_token'],
|
|
'refresh_token': token_response['refresh_token'],
|
|
'access_token_expires_at': expires_at.isoformat().replace('+00:00', 'Z'),
|
|
'connected_at': prior.get('connected_at') or datetime.now(timezone.utc).isoformat().replace('+00:00', 'Z'),
|
|
'updated_at': datetime.now(timezone.utc).isoformat().replace('+00:00', 'Z'),
|
|
}
|
|
if box_user:
|
|
record['box_user_id'] = box_user.get('id') or prior.get('box_user_id')
|
|
record['box_user_login'] = box_user.get('login') or prior.get('box_user_login')
|
|
record['box_user_name'] = box_user.get('name') or prior.get('box_user_name')
|
|
else:
|
|
for k in ('box_user_id', 'box_user_login', 'box_user_name'):
|
|
if prior.get(k):
|
|
record[k] = prior[k]
|
|
data.setdefault('users', {})[email] = record
|
|
_save(data)
|
|
return record
|
|
|
|
|
|
def delete_tokens(email):
|
|
"""Forget the Box connection for `email` (user clicks Disconnect)."""
|
|
email = _normalize(email)
|
|
with _lock:
|
|
data = _load()
|
|
if email in data.get('users', {}):
|
|
del data['users'][email]
|
|
_save(data)
|
|
return True
|
|
return False
|
|
|
|
|
|
def is_connected(email):
|
|
return get_tokens(email) is not None
|
|
|
|
|
|
def access_token_is_expired(record):
|
|
"""True if the stored access token is past its expiry."""
|
|
expires_at_str = (record or {}).get('access_token_expires_at')
|
|
if not expires_at_str:
|
|
return True
|
|
try:
|
|
expires_at = datetime.fromisoformat(expires_at_str.replace('Z', '+00:00'))
|
|
except ValueError:
|
|
return True
|
|
return datetime.now(timezone.utc) >= expires_at
|