From aa88ba363d9efac9cf997e61e80c8d02c73b5fb9 Mon Sep 17 00:00:00 2001 From: Paul Huliganga Date: Fri, 17 Apr 2026 15:24:50 -0400 Subject: [PATCH] fix: DocuSign connects via JWT grant from .env, no browser sign-in Replaces the DocuSign OAuth redirect flow with a direct JWT grant call using credentials already in .env (same as the CLI). Clicking "Connect DocuSign" now calls GET /api/auth/docusign/connect which calls get_access_token() and stores the result in the session cookie. No email sign-in required. Co-Authored-By: Claude Sonnet 4.6 --- tests/test_api_auth.py | 21 +++++++-------- web/routers/auth.py | 60 +++++++++++++----------------------------- web/static/app.js | 19 +++++++++++-- 3 files changed, 44 insertions(+), 56 deletions(-) diff --git a/tests/test_api_auth.py b/tests/test_api_auth.py index 38603a2..2973148 100644 --- a/tests/test_api_auth.py +++ b/tests/test_api_auth.py @@ -70,19 +70,16 @@ def test_adobe_exchange_rejects_missing_code(): assert resp.status_code == 400 -@respx.mock -def test_docusign_callback_stores_token(): - """Successful DocuSign OAuth callback → session has docusign_access_token.""" - from web.config import settings - respx.post(f"https://{settings.docusign_auth_server}/oauth/token").mock( - return_value=httpx.Response(200, json={ - "access_token": "ds-test-token", - "refresh_token": "ds-refresh", - }) - ) +def test_docusign_connect_stores_token(): + """GET /api/auth/docusign/connect uses JWT grant from .env → session connected.""" + from unittest.mock import patch + import web.routers.auth as auth_module - resp = client.get("/api/auth/docusign/callback?code=dscode123", follow_redirects=False) - assert resp.status_code in (302, 307) + with patch("docusign_auth.get_access_token", return_value="ds-jwt-token"): + resp = client.get("/api/auth/docusign/connect") + + assert resp.status_code == 200 + assert resp.json()["connected"] is True session_cookie = resp.cookies.get("migrator_session") assert session_cookie is not None diff --git a/web/routers/auth.py b/web/routers/auth.py index c4eece5..74bcfa5 100644 --- a/web/routers/auth.py +++ b/web/routers/auth.py @@ -12,7 +12,6 @@ DocuSign uses a standard redirect callback handled directly by this server. Tokens are stored in a signed session cookie. """ -import base64 from urllib.parse import urlparse, parse_qs import httpx @@ -128,52 +127,30 @@ def adobe_disconnect(request: Request): # --------------------------------------------------------------------------- -# DocuSign — standard redirect callback +# DocuSign — JWT grant using existing .env credentials # --------------------------------------------------------------------------- -@router.get("/docusign/start") -def docusign_start(): - """Redirect the browser to the DocuSign OAuth authorization page.""" - params = ( - f"?response_type=code" - f"&scope=signature" - f"&client_id={settings.docusign_client_id}" - f"&redirect_uri={settings.docusign_redirect_uri}" - ) - auth_url = f"https://{settings.docusign_auth_server}/oauth/auth" + params - return RedirectResponse(auth_url) +@router.get("/docusign/connect") +def docusign_connect(request: Request): + """ + Obtain a DocuSign access token via JWT grant using the credentials already + in .env (DOCUSIGN_CLIENT_ID, DOCUSIGN_USER_ID, DOCUSIGN_PRIVATE_KEY_PATH). + No browser sign-in needed — consent was already granted via the CLI setup. + """ + import sys + import os + sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src")) + from docusign_auth import get_access_token + try: + token = get_access_token() + except RuntimeError as e: + return JSONResponse({"error": str(e)}, status_code=500) -@router.get("/docusign/callback") -async def docusign_callback(request: Request, code: str = ""): - """Exchange DocuSign authorization code for access token.""" - 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") + session["docusign_access_token"] = token - response = RedirectResponse("/") + response = JSONResponse({"connected": True}) save_session(response, session) return response @@ -182,7 +159,6 @@ async def docusign_callback(request: Request, code: str = ""): 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 39ef449..3d7f7f3 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -38,13 +38,13 @@ function renderAuthBar() { ? () => disconnectPlatform('adobe') : () => startAdobeAuth(); - // DocuSign: standard redirect + // DocuSign: JWT grant from .env — no browser sign-in needed const dsEl = $('badge-docusign'); dsEl.textContent = authState.docusign ? '✓ DocuSign' : 'Connect DocuSign'; dsEl.className = 'auth-badge' + (authState.docusign ? ' connected' : ''); dsEl.onclick = authState.docusign ? () => disconnectPlatform('docusign') - : () => { window.location.href = '/api/auth/docusign/start'; }; + : () => connectDocusign(); } async function disconnectPlatform(platform) { @@ -54,6 +54,21 @@ async function disconnectPlatform(platform) { await refreshTemplates(); } +async function connectDocusign() { + const dsEl = $('badge-docusign'); + dsEl.textContent = 'Connecting…'; + const resp = await fetch('/api/auth/docusign/connect'); + const data = await resp.json(); + if (data.connected) { + authState.docusign = true; + renderAuthBar(); + await refreshTemplates(); + } else { + dsEl.textContent = 'Connect DocuSign'; + setStatus('DocuSign error: ' + (data.error || 'unknown')); + } +} + // Adobe Sign uses the same manual-paste flow as the CLI: // 1. Open auth URL in new tab // 2. User authorizes → lands on failed https://localhost:8080/callback page