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 assert resp.status_code == 400
@respx.mock def test_docusign_connect_stores_token():
def test_docusign_callback_stores_token(): """GET /api/auth/docusign/connect uses JWT grant from .env → session connected."""
"""Successful DocuSign OAuth callback → session has docusign_access_token.""" from unittest.mock import patch
from web.config import settings import web.routers.auth as auth_module
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",
})
)
resp = client.get("/api/auth/docusign/callback?code=dscode123", follow_redirects=False) with patch("docusign_auth.get_access_token", return_value="ds-jwt-token"):
assert resp.status_code in (302, 307) 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") session_cookie = resp.cookies.get("migrator_session")
assert session_cookie is not None 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. Tokens are stored in a signed session cookie.
""" """
import base64
from urllib.parse import urlparse, parse_qs from urllib.parse import urlparse, parse_qs
import httpx 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") @router.get("/docusign/connect")
def docusign_start(): def docusign_connect(request: Request):
"""Redirect the browser to the DocuSign OAuth authorization page.""" """
params = ( Obtain a DocuSign access token via JWT grant using the credentials already
f"?response_type=code" in .env (DOCUSIGN_CLIENT_ID, DOCUSIGN_USER_ID, DOCUSIGN_PRIVATE_KEY_PATH).
f"&scope=signature" No browser sign-in needed consent was already granted via the CLI setup.
f"&client_id={settings.docusign_client_id}" """
f"&redirect_uri={settings.docusign_redirect_uri}" import sys
) import os
auth_url = f"https://{settings.docusign_auth_server}/oauth/auth" + params sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src"))
return RedirectResponse(auth_url) 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 = get_session(request)
session["docusign_access_token"] = token_data.get("access_token") session["docusign_access_token"] = token
session["docusign_refresh_token"] = token_data.get("refresh_token")
response = RedirectResponse("/") response = JSONResponse({"connected": True})
save_session(response, session) save_session(response, session)
return response return response
@ -182,7 +159,6 @@ async def docusign_callback(request: Request, code: str = ""):
def docusign_disconnect(request: Request): def docusign_disconnect(request: Request):
session = get_session(request) session = get_session(request)
session.pop("docusign_access_token", None) session.pop("docusign_access_token", None)
session.pop("docusign_refresh_token", None)
response = JSONResponse({"disconnected": "docusign"}) response = JSONResponse({"disconnected": "docusign"})
save_session(response, session) save_session(response, session)
return response return response

View File

@ -38,13 +38,13 @@ function renderAuthBar() {
? () => disconnectPlatform('adobe') ? () => disconnectPlatform('adobe')
: () => startAdobeAuth(); : () => startAdobeAuth();
// DocuSign: standard redirect // DocuSign: JWT grant from .env — no browser sign-in needed
const dsEl = $('badge-docusign'); const dsEl = $('badge-docusign');
dsEl.textContent = authState.docusign ? '✓ DocuSign' : 'Connect DocuSign'; dsEl.textContent = authState.docusign ? '✓ DocuSign' : 'Connect DocuSign';
dsEl.className = 'auth-badge' + (authState.docusign ? ' connected' : ''); dsEl.className = 'auth-badge' + (authState.docusign ? ' connected' : '');
dsEl.onclick = authState.docusign dsEl.onclick = authState.docusign
? () => disconnectPlatform('docusign') ? () => disconnectPlatform('docusign')
: () => { window.location.href = '/api/auth/docusign/start'; }; : () => connectDocusign();
} }
async function disconnectPlatform(platform) { async function disconnectPlatform(platform) {
@ -54,6 +54,21 @@ async function disconnectPlatform(platform) {
await refreshTemplates(); 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: // Adobe Sign uses the same manual-paste flow as the CLI:
// 1. Open auth URL in new tab // 1. Open auth URL in new tab
// 2. User authorizes → lands on failed https://localhost:8080/callback page // 2. User authorizes → lands on failed https://localhost:8080/callback page