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 <noreply@anthropic.com>
This commit is contained in:
Paul Huliganga 2026-04-17 15:24:50 -04:00
parent 1383586d91
commit aa88ba363d
3 changed files with 44 additions and 56 deletions

View File

@ -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

View File

@ -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

View File

@ -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