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 fastapi.testclient import TestClient
from web.app import app from web.app import app
from web.routers.auth import _ADOBE_TOKEN_URL
client = TestClient(app, raise_server_exceptions=True) client = TestClient(app, raise_server_exceptions=True)
@ -24,29 +25,51 @@ def test_status_unauthenticated():
assert data["docusign"] is False 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 @respx.mock
def test_adobe_callback_stores_token(): def test_adobe_exchange_stores_token():
"""Successful Adobe OAuth callback → session has adobe_access_token.""" """POST /api/auth/adobe/exchange with a valid redirect URL → session connected."""
respx.post("https://api.eu2.adobesign.com/oauth/v2/token").mock( respx.post(_ADOBE_TOKEN_URL).mock(
return_value=httpx.Response(200, json={ return_value=httpx.Response(200, json={
"access_token": "adobe-test-token", "access_token": "adobe-test-token",
"refresh_token": "adobe-refresh", "refresh_token": "adobe-refresh",
}) })
) )
resp = client.get("/api/auth/adobe/callback?code=authcode123", follow_redirects=False) resp = client.post(
# Should redirect to / "/api/auth/adobe/exchange",
assert resp.status_code in (302, 307) 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") session_cookie = resp.cookies.get("migrator_session")
assert session_cookie is not None 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}) status_resp = client.get("/api/auth/status", cookies={"migrator_session": session_cookie})
assert status_resp.json()["adobe"] is True 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 @respx.mock
def test_docusign_callback_stores_token(): def test_docusign_callback_stores_token():
"""Successful DocuSign OAuth callback → session has docusign_access_token.""" """Successful DocuSign OAuth callback → session has docusign_access_token."""
@ -71,22 +94,23 @@ def test_docusign_callback_stores_token():
@respx.mock @respx.mock
def test_disconnect_clears_token(): def test_disconnect_clears_token():
"""After disconnect, status shows platform as disconnected.""" """After disconnect, status shows platform as disconnected."""
# First connect Adobe respx.post(_ADOBE_TOKEN_URL).mock(
respx.post("https://api.eu2.adobesign.com/oauth/v2/token").mock(
return_value=httpx.Response(200, json={"access_token": "tok", "refresh_token": "ref"}) 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"] session_cookie = connect_resp.cookies["migrator_session"]
# Verify connected
status_resp = client.get("/api/auth/status", cookies={"migrator_session": session_cookie}) status_resp = client.get("/api/auth/status", cookies={"migrator_session": session_cookie})
assert status_resp.json()["adobe"] is True assert status_resp.json()["adobe"] is True
# Disconnect
disc_resp = client.get("/api/auth/adobe/disconnect", cookies={"migrator_session": session_cookie}) disc_resp = client.get("/api/auth/adobe/disconnect", cookies={"migrator_session": session_cookie})
assert disc_resp.status_code == 200 assert disc_resp.status_code == 200
new_cookie = disc_resp.cookies.get("migrator_session", session_cookie) new_cookie = disc_resp.cookies.get("migrator_session", session_cookie)
# Verify disconnected
status_resp2 = client.get("/api/auth/status", cookies={"migrator_session": new_cookie}) status_resp2 = client.get("/api/auth/status", cookies={"migrator_session": new_cookie})
assert status_resp2.json()["adobe"] is False assert status_resp2.json()["adobe"] is False

View File

@ -3,21 +3,33 @@ web/routers/auth.py
------------------- -------------------
OAuth endpoints for Adobe Sign and DocuSign. OAuth endpoints for Adobe Sign and DocuSign.
Adobe Sign: Authorization Code flow Adobe Sign uses the same redirect URI as the CLI (https://localhost:8080/callback).
DocuSign: Authorization Code flow (demo sandbox) 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. Tokens are stored in a signed session cookie.
""" """
import base64
from urllib.parse import urlparse, parse_qs
import httpx import httpx
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse, RedirectResponse from fastapi.responses import JSONResponse, RedirectResponse
from pydantic import BaseModel
from web.config import settings 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() 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 # Status
@ -34,49 +46,73 @@ def auth_status(request: Request):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Adobe Sign # Adobe Sign — manual paste flow (matches CLI behaviour)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@router.get("/adobe/start") @router.get("/adobe/url")
def adobe_start(): def adobe_auth_url():
"""Redirect the browser to the Adobe Sign OAuth authorization page.""" """
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 = ( params = (
f"?response_type=code" f"?response_type=code"
f"&client_id={settings.adobe_client_id}" 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" f"&scope=library_read:self+library_write:self+user_read:self"
) )
auth_url = "https://secure.eu2.adobesign.com/public/oauth/v2" + params return {"url": _ADOBE_AUTH_URL + params}
return RedirectResponse(auth_url)
@router.get("/adobe/callback") class AdobeExchangeRequest(BaseModel):
async def adobe_callback(request: Request, code: str = ""): redirect_url: str
"""Exchange authorization code for access + refresh tokens."""
if not code:
return JSONResponse({"error": "missing code"}, status_code=400) @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: async with httpx.AsyncClient() as client:
resp = await client.post( resp = await client.post(
"https://api.eu2.adobesign.com/oauth/v2/token", _ADOBE_TOKEN_URL,
data={ data={
"grant_type": "authorization_code", "grant_type": "authorization_code",
"client_id": settings.adobe_client_id, "client_id": settings.adobe_client_id,
"client_secret": settings.adobe_client_secret, "client_secret": settings.adobe_client_secret,
"redirect_uri": settings.adobe_redirect_uri, "redirect_uri": _ADOBE_REDIRECT_URI,
"code": code, "code": code,
}, },
) )
if not resp.is_success: 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() 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 = get_session(request)
session["adobe_access_token"] = token_data.get("access_token") session["adobe_access_token"] = token_data.get("access_token")
session["adobe_refresh_token"] = token_data.get("refresh_token") session["adobe_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
@ -92,7 +128,7 @@ def adobe_disconnect(request: Request):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# DocuSign # DocuSign — standard redirect callback
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@router.get("/docusign/start") @router.get("/docusign/start")
@ -110,11 +146,10 @@ def docusign_start():
@router.get("/docusign/callback") @router.get("/docusign/callback")
async def docusign_callback(request: Request, code: str = ""): async def docusign_callback(request: Request, code: str = ""):
"""Exchange authorization code for access token.""" """Exchange DocuSign authorization code for access token."""
if not code: if not code:
return JSONResponse({"error": "missing code"}, status_code=400) return JSONResponse({"error": "missing code"}, status_code=400)
import base64
credentials = base64.b64encode( credentials = base64.b64encode(
f"{settings.docusign_client_id}:{settings.docusign_client_secret}".encode() f"{settings.docusign_client_id}:{settings.docusign_client_secret}".encode()
).decode() ).decode()

View File

@ -3,9 +3,8 @@
const $ = id => document.getElementById(id); const $ = id => document.getElementById(id);
let adobeTemplates = []; // [{id, name, modifiedDate}]
let dsTemplates = []; // [{id, name, lastModified}]
let statusTemplates = []; // [{adobe_id, name, status, docusign_id, ...}] let statusTemplates = []; // [{adobe_id, name, status, docusign_id, ...}]
let dsTemplates = []; // [{id, name, lastModified}]
let authState = { adobe: false, docusign: false }; let authState = { adobe: false, docusign: false };
// ── Init ──────────────────────────────────────────────────────────────────── // ── Init ────────────────────────────────────────────────────────────────────
@ -31,21 +30,110 @@ async function refreshAuth() {
} }
function renderAuthBar() { function renderAuthBar() {
renderAuthBadge('badge-adobe', 'Adobe Sign', authState.adobe, '/api/auth/adobe/start', '/api/auth/adobe/disconnect'); // Adobe: manual paste flow
renderAuthBadge('badge-docusign', 'DocuSign', authState.docusign, '/api/auth/docusign/start', '/api/auth/docusign/disconnect'); 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) { async function disconnectPlatform(platform) {
const el = $(id); await fetch(`/api/auth/${platform}/disconnect`);
el.textContent = connected ? `${label}` : `Connect ${label}`; authState[platform] = false;
el.className = 'auth-badge' + (connected ? ' connected' : ''); renderAuthBar();
el.onclick = () => { await refreshTemplates();
if (connected) { }
fetch(disconnectUrl).then(() => { authState[id.replace('badge-','')] = false; renderAuthBar(); refreshTemplates(); });
} else { // Adobe Sign uses the same manual-paste flow as the CLI:
window.location.href = connectUrl; // 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 ──────────────────────────────────────────────────────────────── // ── Templates ────────────────────────────────────────────────────────────────
@ -70,7 +158,7 @@ async function refreshTemplates() {
fetch('/api/templates/docusign'), fetch('/api/templates/docusign'),
]); ]);
statusTemplates = (await statusResp.json()).templates || []; statusTemplates = (await statusResp.json()).templates || [];
dsTemplates = ((await dsResp.json()).templates || []); dsTemplates = (await dsResp.json()).templates || [];
renderAdobeList(statusTemplates); renderAdobeList(statusTemplates);
renderDsList(dsTemplates); renderDsList(dsTemplates);
setStatus(`${statusTemplates.length} Adobe template(s) loaded.`); setStatus(`${statusTemplates.length} Adobe template(s) loaded.`);
@ -138,7 +226,6 @@ async function onMigrate() {
$('btn-migrate').disabled = true; $('btn-migrate').disabled = true;
setStatus(`Migrating ${ids.length} template(s)…`); setStatus(`Migrating ${ids.length} template(s)…`);
// Show spinners
ids.forEach(id => { ids.forEach(id => {
const spin = $('spin-' + id); const spin = $('spin-' + id);
if (spin) spin.textContent = '⏳'; 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; } .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 ── */ /* ── Responsive ── */
@media (max-width: 700px) { @media (max-width: 700px) {
.panel-row { grid-template-columns: 1fr; } .panel-row { grid-template-columns: 1fr; }