fix: Adobe Sign connects via .env refresh token; restore DocuSign OAuth

- GET /api/auth/adobe/connect: reads ADOBE_REFRESH_TOKEN from .env,
  refreshes the access token, stores in session — no login required
- Falls back to OAuth dialog only if no .env credentials exist
- Restores DocuSign OAuth start/callback endpoints alongside JWT connect
- 33/33 tests passing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Paul Huliganga 2026-04-17 15:27:42 -04:00
parent aa88ba363d
commit c63d49e208
3 changed files with 134 additions and 3 deletions

View File

@ -37,6 +37,31 @@ def test_adobe_url_returns_auth_url():
assert "localhost%3A8080" in data["url"] or "localhost:8080" in data["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 @respx.mock
def test_adobe_exchange_stores_token(): def test_adobe_exchange_stores_token():
"""POST /api/auth/adobe/exchange with a valid redirect URL → session connected.""" """POST /api/auth/adobe/exchange with a valid redirect URL → session connected."""

View File

@ -116,6 +116,43 @@ async def adobe_exchange(body: AdobeExchangeRequest, request: Request):
return response 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") @router.get("/adobe/disconnect")
def adobe_disconnect(request: Request): def adobe_disconnect(request: Request):
session = get_session(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") @router.get("/docusign/connect")
@ -155,10 +192,59 @@ def docusign_connect(request: Request):
return response 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") @router.get("/docusign/disconnect")
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

@ -30,13 +30,13 @@ async function refreshAuth() {
} }
function renderAuthBar() { function renderAuthBar() {
// Adobe: manual paste flow // Adobe: use .env credentials (primary), OAuth dialog (secondary)
const adobeEl = $('badge-adobe'); const adobeEl = $('badge-adobe');
adobeEl.textContent = authState.adobe ? '✓ Adobe Sign' : 'Connect Adobe Sign'; adobeEl.textContent = authState.adobe ? '✓ Adobe Sign' : 'Connect Adobe Sign';
adobeEl.className = 'auth-badge' + (authState.adobe ? ' connected' : ''); adobeEl.className = 'auth-badge' + (authState.adobe ? ' connected' : '');
adobeEl.onclick = authState.adobe adobeEl.onclick = authState.adobe
? () => disconnectPlatform('adobe') ? () => disconnectPlatform('adobe')
: () => startAdobeAuth(); : () => connectAdobeEnv();
// DocuSign: JWT grant from .env — no browser sign-in needed // DocuSign: JWT grant from .env — no browser sign-in needed
const dsEl = $('badge-docusign'); const dsEl = $('badge-docusign');
@ -54,6 +54,26 @@ async function disconnectPlatform(platform) {
await refreshTemplates(); 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() { async function connectDocusign() {
const dsEl = $('badge-docusign'); const dsEl = $('badge-docusign');
dsEl.textContent = 'Connecting…'; dsEl.textContent = 'Connecting…';