diff --git a/tests/test_api_auth.py b/tests/test_api_auth.py index d7e7c2e..38603a2 100644 --- a/tests/test_api_auth.py +++ b/tests/test_api_auth.py @@ -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 diff --git a/web/routers/auth.py b/web/routers/auth.py index fc3eaee..c4eece5 100644 --- a/web/routers/auth.py +++ b/web/routers/auth.py @@ -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() diff --git a/web/static/app.js b/web/static/app.js index c0040ac..39ef449 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -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 = ` +
+
+

Connect Adobe Sign

+
    +
  1. Click here to authorize in Adobe Sign
  2. +
  3. After authorizing, your browser will show a page that fails to load — that's expected.
  4. +
  5. Copy the full URL from the address bar and paste it below.
  6. +
+ +
+
+ + +
+
+ `; + 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 = '⏳'; diff --git a/web/static/style.css b/web/static/style.css index 28e940b..b8194e6 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -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; }