fix: use existing Adobe Sign redirect URI (localhost:8080) in web UI

The Adobe Sign OAuth app has https://localhost:8080/callback registered
(same as CLI). The web UI now uses the same manual paste flow:
- GET /api/auth/adobe/url returns the auth URL for the frontend to open
- POST /api/auth/adobe/exchange accepts the full redirect URL the user
  copies after authorizing, extracts the code, exchanges for tokens
- Dialog UI guides user through the 3-step process

DocuSign keeps its standard redirect callback flow unchanged.
31/31 tests passing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Paul Huliganga 2026-04-17 15:20:10 -04:00
parent f78a50282a
commit 1383586d91
4 changed files with 231 additions and 52 deletions

View File

@ -11,6 +11,7 @@ import httpx
from fastapi.testclient import TestClient
from web.app import app
from web.routers.auth import _ADOBE_TOKEN_URL
client = TestClient(app, raise_server_exceptions=True)
@ -24,29 +25,51 @@ def test_status_unauthenticated():
assert data["docusign"] is False
def test_adobe_url_returns_auth_url():
"""GET /api/auth/adobe/url returns an Adobe Sign authorization URL."""
resp = client.get("/api/auth/adobe/url")
assert resp.status_code == 200
data = resp.json()
assert "url" in data
assert "adobesign.com" in data["url"]
assert "response_type=code" in data["url"]
# Must use the registered redirect URI
assert "localhost%3A8080" in data["url"] or "localhost:8080" in data["url"]
@respx.mock
def test_adobe_callback_stores_token():
"""Successful Adobe OAuth callback → session has adobe_access_token."""
respx.post("https://api.eu2.adobesign.com/oauth/v2/token").mock(
def test_adobe_exchange_stores_token():
"""POST /api/auth/adobe/exchange with a valid redirect URL → session connected."""
respx.post(_ADOBE_TOKEN_URL).mock(
return_value=httpx.Response(200, json={
"access_token": "adobe-test-token",
"refresh_token": "adobe-refresh",
})
)
resp = client.get("/api/auth/adobe/callback?code=authcode123", follow_redirects=False)
# Should redirect to /
assert resp.status_code in (302, 307)
resp = client.post(
"/api/auth/adobe/exchange",
json={"redirect_url": "https://localhost:8080/callback?code=authcode123&api_access_point=https://api.eu2.adobesign.com/"},
)
assert resp.status_code == 200
assert resp.json()["connected"] is True
# Session cookie should now contain the token
session_cookie = resp.cookies.get("migrator_session")
assert session_cookie is not None
# Follow up with status check using the same session cookie
status_resp = client.get("/api/auth/status", cookies={"migrator_session": session_cookie})
assert status_resp.json()["adobe"] is True
def test_adobe_exchange_rejects_missing_code():
"""POST /api/auth/adobe/exchange with no code in URL → 400."""
resp = client.post(
"/api/auth/adobe/exchange",
json={"redirect_url": "https://localhost:8080/callback?error=access_denied"},
)
assert resp.status_code == 400
@respx.mock
def test_docusign_callback_stores_token():
"""Successful DocuSign OAuth callback → session has docusign_access_token."""
@ -71,22 +94,23 @@ def test_docusign_callback_stores_token():
@respx.mock
def test_disconnect_clears_token():
"""After disconnect, status shows platform as disconnected."""
# First connect Adobe
respx.post("https://api.eu2.adobesign.com/oauth/v2/token").mock(
respx.post(_ADOBE_TOKEN_URL).mock(
return_value=httpx.Response(200, json={"access_token": "tok", "refresh_token": "ref"})
)
connect_resp = client.get("/api/auth/adobe/callback?code=abc", follow_redirects=False)
# Connect Adobe via exchange
connect_resp = client.post(
"/api/auth/adobe/exchange",
json={"redirect_url": "https://localhost:8080/callback?code=abc"},
)
session_cookie = connect_resp.cookies["migrator_session"]
# Verify connected
status_resp = client.get("/api/auth/status", cookies={"migrator_session": session_cookie})
assert status_resp.json()["adobe"] is True
# Disconnect
disc_resp = client.get("/api/auth/adobe/disconnect", cookies={"migrator_session": session_cookie})
assert disc_resp.status_code == 200
new_cookie = disc_resp.cookies.get("migrator_session", session_cookie)
# Verify disconnected
status_resp2 = client.get("/api/auth/status", cookies={"migrator_session": new_cookie})
assert status_resp2.json()["adobe"] is False

View File

@ -3,21 +3,33 @@ web/routers/auth.py
-------------------
OAuth endpoints for Adobe Sign and DocuSign.
Adobe Sign: Authorization Code flow
DocuSign: Authorization Code flow (demo sandbox)
Adobe Sign uses the same redirect URI as the CLI (https://localhost:8080/callback).
Since nothing runs on that port, the browser lands on a failed page. The user copies
the URL and submits it via POST /api/auth/adobe/exchange identical to the CLI flow.
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
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse, RedirectResponse
from pydantic import BaseModel
from web.config import settings
from web.session import get_session, save_session, clear_session
from web.session import get_session, save_session
router = APIRouter()
# Adobe Sign registers https://localhost:8080/callback — same as the CLI script.
_ADOBE_REDIRECT_URI = "https://localhost:8080/callback"
_ADOBE_AUTH_URL = "https://secure.eu2.adobesign.com/public/oauth/v2"
_ADOBE_TOKEN_URL = "https://api.eu2.adobesign.com/oauth/v2/token"
# ---------------------------------------------------------------------------
# Status
@ -34,49 +46,73 @@ def auth_status(request: Request):
# ---------------------------------------------------------------------------
# Adobe Sign
# Adobe Sign — manual paste flow (matches CLI behaviour)
# ---------------------------------------------------------------------------
@router.get("/adobe/start")
def adobe_start():
"""Redirect the browser to the Adobe Sign OAuth authorization page."""
@router.get("/adobe/url")
def adobe_auth_url():
"""
Return the Adobe Sign authorization URL for the frontend to open in a new tab.
The user authorizes, lands on a failed page (nothing runs on :8080), copies
the URL, and submits it to POST /api/auth/adobe/exchange.
"""
params = (
f"?response_type=code"
f"&client_id={settings.adobe_client_id}"
f"&redirect_uri={settings.adobe_redirect_uri}"
f"&redirect_uri={_ADOBE_REDIRECT_URI}"
f"&scope=library_read:self+library_write:self+user_read:self"
)
auth_url = "https://secure.eu2.adobesign.com/public/oauth/v2" + params
return RedirectResponse(auth_url)
return {"url": _ADOBE_AUTH_URL + params}
@router.get("/adobe/callback")
async def adobe_callback(request: Request, code: str = ""):
"""Exchange authorization code for access + refresh tokens."""
if not code:
return JSONResponse({"error": "missing code"}, status_code=400)
class AdobeExchangeRequest(BaseModel):
redirect_url: str
@router.post("/adobe/exchange")
async def adobe_exchange(body: AdobeExchangeRequest, request: Request):
"""
Accept the full redirect URL that the user copied after Adobe Sign authorization
(e.g. https://localhost:8080/callback?code=...&api_access_point=...).
Extract the code and exchange it for tokens.
"""
parsed = urlparse(body.redirect_url)
params = parse_qs(parsed.query)
if "error" in params:
error = params.get("error_description", params.get("error", ["unknown"]))[0]
return JSONResponse({"error": f"Adobe Sign returned error: {error}"}, status_code=400)
code_list = params.get("code")
if not code_list:
return JSONResponse({"error": "No code found in the URL. Did you paste the correct redirect URL?"}, status_code=400)
code = code_list[0]
async with httpx.AsyncClient() as client:
resp = await client.post(
"https://api.eu2.adobesign.com/oauth/v2/token",
_ADOBE_TOKEN_URL,
data={
"grant_type": "authorization_code",
"client_id": settings.adobe_client_id,
"client_secret": settings.adobe_client_secret,
"redirect_uri": settings.adobe_redirect_uri,
"redirect_uri": _ADOBE_REDIRECT_URI,
"code": code,
},
)
if not resp.is_success:
return JSONResponse({"error": "token exchange failed", "detail": resp.text}, status_code=502)
return JSONResponse({"error": "Token exchange failed", "detail": resp.text}, status_code=502)
token_data = resp.json()
if "error" in token_data:
return JSONResponse({"error": token_data.get("error_description", token_data["error"])}, status_code=400)
session = get_session(request)
session["adobe_access_token"] = token_data.get("access_token")
session["adobe_refresh_token"] = token_data.get("refresh_token")
response = RedirectResponse("/")
response = JSONResponse({"connected": True})
save_session(response, session)
return response
@ -92,7 +128,7 @@ def adobe_disconnect(request: Request):
# ---------------------------------------------------------------------------
# DocuSign
# DocuSign — standard redirect callback
# ---------------------------------------------------------------------------
@router.get("/docusign/start")
@ -110,11 +146,10 @@ def docusign_start():
@router.get("/docusign/callback")
async def docusign_callback(request: Request, code: str = ""):
"""Exchange authorization code for access token."""
"""Exchange DocuSign authorization code for access token."""
if not code:
return JSONResponse({"error": "missing code"}, status_code=400)
import base64
credentials = base64.b64encode(
f"{settings.docusign_client_id}:{settings.docusign_client_secret}".encode()
).decode()

View File

@ -3,9 +3,8 @@
const $ = id => document.getElementById(id);
let adobeTemplates = []; // [{id, name, modifiedDate}]
let dsTemplates = []; // [{id, name, lastModified}]
let statusTemplates = []; // [{adobe_id, name, status, docusign_id, ...}]
let dsTemplates = []; // [{id, name, lastModified}]
let authState = { adobe: false, docusign: false };
// ── Init ────────────────────────────────────────────────────────────────────
@ -31,21 +30,110 @@ async function refreshAuth() {
}
function renderAuthBar() {
renderAuthBadge('badge-adobe', 'Adobe Sign', authState.adobe, '/api/auth/adobe/start', '/api/auth/adobe/disconnect');
renderAuthBadge('badge-docusign', 'DocuSign', authState.docusign, '/api/auth/docusign/start', '/api/auth/docusign/disconnect');
// Adobe: manual paste flow
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();
// DocuSign: standard redirect
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'; };
}
function renderAuthBadge(id, label, connected, connectUrl, disconnectUrl) {
const el = $(id);
el.textContent = connected ? `${label}` : `Connect ${label}`;
el.className = 'auth-badge' + (connected ? ' connected' : '');
el.onclick = () => {
if (connected) {
fetch(disconnectUrl).then(() => { authState[id.replace('badge-','')] = false; renderAuthBar(); refreshTemplates(); });
} else {
window.location.href = connectUrl;
async function disconnectPlatform(platform) {
await fetch(`/api/auth/${platform}/disconnect`);
authState[platform] = false;
renderAuthBar();
await refreshTemplates();
}
// 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
// 3. User copies that URL, pastes it into the dialog here
// 4. We POST it to /api/auth/adobe/exchange
async function startAdobeAuth() {
const resp = await fetch('/api/auth/adobe/url');
const { url } = await resp.json();
showAdobeDialog(url);
}
function showAdobeDialog(authUrl) {
// Remove any existing dialog
const existing = $('adobe-auth-dialog');
if (existing) existing.remove();
const dialog = document.createElement('div');
dialog.id = 'adobe-auth-dialog';
dialog.innerHTML = `
<div class="dialog-backdrop"></div>
<div class="dialog-box">
<h2>Connect Adobe Sign</h2>
<ol>
<li><a href="${escHtml(authUrl)}" target="_blank" rel="noopener" id="adobe-auth-link">Click here to authorize in Adobe Sign</a></li>
<li>After authorizing, your browser will show a page that fails to load that's expected.</li>
<li>Copy the full URL from the address bar and paste it below.</li>
</ol>
<input type="text" id="adobe-redirect-input" placeholder="https://localhost:8080/callback?code=…" />
<div class="dialog-error" id="dialog-error"></div>
<div class="dialog-actions">
<button id="btn-submit-code">Connect</button>
<button id="btn-cancel-dialog" class="btn-secondary">Cancel</button>
</div>
</div>
`;
document.body.appendChild(dialog);
$('btn-cancel-dialog').onclick = () => dialog.remove();
$('btn-submit-code').onclick = () => submitAdobeCode(dialog);
// Also handle Enter key
$('adobe-redirect-input').addEventListener('keydown', e => {
if (e.key === 'Enter') submitAdobeCode(dialog);
});
}
async function submitAdobeCode(dialog) {
const url = $('adobe-redirect-input').value.trim();
if (!url) return;
$('btn-submit-code').disabled = true;
$('btn-submit-code').textContent = 'Connecting…';
$('dialog-error').textContent = '';
try {
const resp = await fetch('/api/auth/adobe/exchange', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ redirect_url: url }),
});
const data = await resp.json();
if (!resp.ok || data.error) {
$('dialog-error').textContent = data.error || 'Connection failed.';
$('btn-submit-code').disabled = false;
$('btn-submit-code').textContent = 'Connect';
return;
}
dialog.remove();
authState.adobe = true;
renderAuthBar();
await refreshTemplates();
} catch (e) {
$('dialog-error').textContent = 'Error: ' + e.message;
$('btn-submit-code').disabled = false;
$('btn-submit-code').textContent = 'Connect';
}
};
}
// ── Templates ────────────────────────────────────────────────────────────────
@ -70,7 +158,7 @@ async function refreshTemplates() {
fetch('/api/templates/docusign'),
]);
statusTemplates = (await statusResp.json()).templates || [];
dsTemplates = ((await dsResp.json()).templates || []);
dsTemplates = (await dsResp.json()).templates || [];
renderAdobeList(statusTemplates);
renderDsList(dsTemplates);
setStatus(`${statusTemplates.length} Adobe template(s) loaded.`);
@ -138,7 +226,6 @@ async function onMigrate() {
$('btn-migrate').disabled = true;
setStatus(`Migrating ${ids.length} template(s)…`);
// Show spinners
ids.forEach(id => {
const spin = $('spin-' + id);
if (spin) spin.textContent = '⏳';

View File

@ -147,6 +147,39 @@ button:disabled { opacity: 0.45; cursor: not-allowed; }
.empty-msg { padding: 20px; text-align: center; color: #999; font-size: 13px; }
/* ── Adobe auth dialog ── */
.dialog-backdrop {
position: fixed; inset: 0;
background: rgba(0,0,0,0.4);
z-index: 100;
}
.dialog-box {
position: fixed;
top: 50%; left: 50%;
transform: translate(-50%, -50%);
background: #fff;
border-radius: 8px;
padding: 28px 32px;
width: min(500px, 90vw);
z-index: 101;
box-shadow: 0 8px 32px rgba(0,0,0,0.18);
}
.dialog-box h2 { font-size: 16px; margin-bottom: 16px; }
.dialog-box ol { padding-left: 20px; margin-bottom: 16px; line-height: 1.7; }
.dialog-box ol a { color: #1a3c5e; }
.dialog-box input[type=text] {
width: 100%;
padding: 8px 10px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 13px;
margin-bottom: 8px;
font-family: monospace;
}
.dialog-error { color: #c00; font-size: 12px; min-height: 18px; margin-bottom: 10px; }
.dialog-actions { display: flex; gap: 8px; justify-content: flex-end; }
.btn-secondary { background: #e9ecef; color: #333; }
/* ── Responsive ── */
@media (max-width: 700px) {
.panel-row { grid-template-columns: 1fr; }