diff --git a/tests/test_api_auth.py b/tests/test_api_auth.py index 2973148..b001fc0 100644 --- a/tests/test_api_auth.py +++ b/tests/test_api_auth.py @@ -37,6 +37,31 @@ def test_adobe_url_returns_auth_url(): assert "localhost%3A8080" in data["url"] or "localhost:8080" in data["url"] +def test_adobe_connect_env_stores_token(monkeypatch): + """GET /api/auth/adobe/connect uses .env refresh token → session connected.""" + monkeypatch.setenv("ADOBE_ACCESS_TOKEN", "existing-token") + monkeypatch.setenv("ADOBE_REFRESH_TOKEN", "existing-refresh") + + from unittest.mock import patch + with patch("adobe_api._refresh_access_token", return_value="refreshed-token"): + resp = client.get("/api/auth/adobe/connect") + + assert resp.status_code == 200 + assert resp.json()["connected"] is True + session_cookie = resp.cookies.get("migrator_session") + status_resp = client.get("/api/auth/status", cookies={"migrator_session": session_cookie}) + assert status_resp.json()["adobe"] is True + + +def test_adobe_connect_env_fails_without_credentials(monkeypatch): + """GET /api/auth/adobe/connect with no .env tokens → 400.""" + monkeypatch.delenv("ADOBE_ACCESS_TOKEN", raising=False) + monkeypatch.delenv("ADOBE_REFRESH_TOKEN", raising=False) + resp = client.get("/api/auth/adobe/connect") + assert resp.status_code == 400 + assert "No Adobe Sign credentials" in resp.json()["error"] + + @respx.mock def test_adobe_exchange_stores_token(): """POST /api/auth/adobe/exchange with a valid redirect URL → session connected.""" diff --git a/web/routers/auth.py b/web/routers/auth.py index 74bcfa5..628b31f 100644 --- a/web/routers/auth.py +++ b/web/routers/auth.py @@ -116,6 +116,43 @@ async def adobe_exchange(body: AdobeExchangeRequest, request: Request): return response +@router.get("/adobe/connect") +def adobe_connect_env(request: Request): + """ + Load Adobe Sign credentials directly from .env (ADOBE_ACCESS_TOKEN / + ADOBE_REFRESH_TOKEN). Refreshes the token if needed. No browser login required + when a valid refresh token already exists from a previous CLI auth session. + """ + import sys + import os + sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src")) + from adobe_api import _refresh_access_token + + token = os.getenv("ADOBE_ACCESS_TOKEN") + refresh_token = os.getenv("ADOBE_REFRESH_TOKEN") + + if not token and not refresh_token: + return JSONResponse( + {"error": "No Adobe Sign credentials found in .env. Run src/adobe_auth.py first."}, + status_code=400, + ) + + # Always refresh to ensure the token is fresh (access tokens expire in ~1h) + if refresh_token: + try: + token = _refresh_access_token() + except RuntimeError as e: + return JSONResponse({"error": str(e)}, status_code=500) + + session = get_session(request) + session["adobe_access_token"] = token + session["adobe_refresh_token"] = refresh_token + + response = JSONResponse({"connected": True}) + save_session(response, session) + return response + + @router.get("/adobe/disconnect") def adobe_disconnect(request: Request): session = get_session(request) @@ -127,7 +164,7 @@ def adobe_disconnect(request: Request): # --------------------------------------------------------------------------- -# DocuSign — JWT grant using existing .env credentials +# DocuSign — JWT grant (.env) or OAuth redirect # --------------------------------------------------------------------------- @router.get("/docusign/connect") @@ -155,10 +192,59 @@ def docusign_connect(request: Request): return response +@router.get("/docusign/start") +def docusign_start(): + """Redirect to DocuSign OAuth (alternative to JWT grant).""" + import base64 as _b64 + params = ( + f"?response_type=code" + f"&scope=signature" + f"&client_id={settings.docusign_client_id}" + f"&redirect_uri={settings.docusign_redirect_uri}" + ) + return RedirectResponse(f"https://{settings.docusign_auth_server}/oauth/auth" + params) + + +@router.get("/docusign/callback") +async def docusign_callback(request: Request, code: str = ""): + """Handle DocuSign OAuth redirect callback.""" + import base64 + if not code: + return JSONResponse({"error": "missing code"}, status_code=400) + + credentials = base64.b64encode( + f"{settings.docusign_client_id}:{settings.docusign_client_secret}".encode() + ).decode() + + async with httpx.AsyncClient() as client: + resp = await client.post( + f"https://{settings.docusign_auth_server}/oauth/token", + headers={"Authorization": f"Basic {credentials}"}, + data={ + "grant_type": "authorization_code", + "code": code, + "redirect_uri": settings.docusign_redirect_uri, + }, + ) + + if not resp.is_success: + return JSONResponse({"error": "token exchange failed", "detail": resp.text}, status_code=502) + + token_data = resp.json() + session = get_session(request) + session["docusign_access_token"] = token_data.get("access_token") + session["docusign_refresh_token"] = token_data.get("refresh_token") + + response = RedirectResponse("/") + save_session(response, session) + return response + + @router.get("/docusign/disconnect") def docusign_disconnect(request: Request): session = get_session(request) session.pop("docusign_access_token", None) + session.pop("docusign_refresh_token", None) response = JSONResponse({"disconnected": "docusign"}) save_session(response, session) return response diff --git a/web/static/app.js b/web/static/app.js index 3d7f7f3..a1832be 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -30,13 +30,13 @@ async function refreshAuth() { } function renderAuthBar() { - // Adobe: manual paste flow + // Adobe: use .env credentials (primary), OAuth dialog (secondary) const adobeEl = $('badge-adobe'); adobeEl.textContent = authState.adobe ? '✓ Adobe Sign' : 'Connect Adobe Sign'; adobeEl.className = 'auth-badge' + (authState.adobe ? ' connected' : ''); adobeEl.onclick = authState.adobe ? () => disconnectPlatform('adobe') - : () => startAdobeAuth(); + : () => connectAdobeEnv(); // DocuSign: JWT grant from .env — no browser sign-in needed const dsEl = $('badge-docusign'); @@ -54,6 +54,26 @@ async function disconnectPlatform(platform) { await refreshTemplates(); } +async function connectAdobeEnv() { + const el = $('badge-adobe'); + el.textContent = 'Connecting…'; + const resp = await fetch('/api/auth/adobe/connect'); + const data = await resp.json(); + if (data.connected) { + authState.adobe = true; + renderAuthBar(); + await refreshTemplates(); + } else { + el.textContent = 'Connect Adobe Sign'; + // If .env has no credentials, fall back to the OAuth dialog + if (data.error && data.error.includes('No Adobe Sign credentials')) { + startAdobeAuth(); + } else { + setStatus('Adobe Sign error: ' + (data.error || 'unknown')); + } + } +} + async function connectDocusign() { const dsEl = $('badge-docusign'); dsEl.textContent = 'Connecting…';