From e19bd68ebd5b70a748b0ae08e9881ee2fae0913c Mon Sep 17 00:00:00 2001 From: Paul Huliganga Date: Wed, 22 Apr 2026 12:00:17 -0400 Subject: [PATCH] Use Adobe OAuth callback flow in web UI --- .env-sample | 1 + README.md | 4 +- tests/test_api_auth.py | 58 ++++++++++-- web/routers/auth.py | 209 +++++++++++++++++++++++++++++++---------- web/static/js/api.js | 4 +- web/static/js/auth.js | 84 ++--------------- 6 files changed, 226 insertions(+), 134 deletions(-) diff --git a/.env-sample b/.env-sample index faee090..524ae89 100644 --- a/.env-sample +++ b/.env-sample @@ -13,6 +13,7 @@ ADOBE_CLIENT_SECRET=your-adobe-client-secret ADOBE_ACCESS_TOKEN= ADOBE_REFRESH_TOKEN= ADOBE_SIGN_BASE_URL=https://api.eu2.adobesign.com/api/rest/v6 +ADOBE_REDIRECT_URI=http://localhost:8000/api/auth/adobe/callback # ─── DocuSign ──────────────────────────────────────────────────────────────── diff --git a/README.md b/README.md index 4f16c5a..f373315 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ variables with descriptions. Use `account-d.docusign.com` and `https://demo.docusign.net/restapi` for sandbox; for production replace with `account.docusign.com` and your account's base URL (e.g. `https://na3.docusign.net/restapi`). -**3. Authenticate with Adobe Sign** (one-time): +**3. Authenticate with Adobe Sign** (one-time for CLI use): ```bash python3 src/adobe_auth.py ``` @@ -126,6 +126,7 @@ The web UI now supports concurrent testers on one shared deployment: - each browser gets its own server-side session file - DocuSign web OAuth is isolated per tester session - migration history and batch-job polling are scoped to that tester session +- Adobe Sign web auth now supports the same redirect-based browser callback pattern as DocuSign - Adobe Sign can still be connected from shared `.env` credentials if you use the top-bar Adobe connect flow Important behavior: @@ -152,6 +153,7 @@ Important behavior: 1. **Create a project** — the switcher modal opens on first run; name it after the customer. 2. **Connect platforms** — click the Adobe Sign and Docusign chips in the top bar. - For group testing, each tester should connect Docusign in their own browser. + - Adobe Sign also supports a normal browser redirect callback when shared `.env` credentials are not being used. - If your DocuSign login belongs to multiple accounts, the app will prompt you to choose one account for this session. - Settings now shows the browser session ID, auth mode, and selected DocuSign account for easier troubleshooting. 3. **Review templates** — the Templates view shows readiness badges: diff --git a/tests/test_api_auth.py b/tests/test_api_auth.py index b1f1001..a409573 100644 --- a/tests/test_api_auth.py +++ b/tests/test_api_auth.py @@ -42,6 +42,7 @@ def _userinfo_payload(): def temp_session_store(tmp_path, monkeypatch): import web.config as cfg monkeypatch.setattr(cfg.settings, "session_store_dir", str(tmp_path / ".session-store")) + monkeypatch.setattr(cfg.settings, "adobe_redirect_uri", "http://localhost:8000/api/auth/adobe/callback") client.cookies.clear() yield client.cookies.clear() @@ -64,8 +65,8 @@ def test_adobe_url_returns_auth_url(): 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"] + assert "redirect_uri=http://localhost:8000/api/auth/adobe/callback" in data["url"] + assert resp.cookies.get("migrator_session") is not None def test_adobe_connect_env_stores_token(monkeypatch): @@ -93,13 +94,14 @@ def test_adobe_connect_env_stores_token(monkeypatch): assert status_resp.json()["adobe_label"] == "Paul Sandbox" -def test_adobe_connect_env_fails_without_credentials(monkeypatch): - """GET /api/auth/adobe/connect with no .env tokens → 400.""" +def test_adobe_connect_requests_authorization_without_credentials(monkeypatch): + """GET /api/auth/adobe/connect with no .env tokens returns an auth URL.""" 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"] + assert resp.status_code == 200 + assert resp.json()["authorization_required"] is True + assert "/api/auth/adobe/callback" in resp.json()["authorization_url"] @respx.mock @@ -126,6 +128,48 @@ def test_adobe_exchange_stores_token(): assert status_resp.json()["adobe"] is True +@respx.mock +def test_adobe_callback_stores_session_tokens(): + """GET /api/auth/adobe/callback stores tokens in this browser session.""" + respx.post(_ADOBE_TOKEN_URL).mock( + return_value=httpx.Response(200, json={ + "access_token": "adobe-test-token", + "refresh_token": "adobe-refresh", + }) + ) + + from unittest.mock import patch + + cookie = create_test_session({"adobe_oauth_state": "expected-state"}) + with patch("web.routers.auth._fetch_adobe_profile", return_value={ + "adobe_user_name": "Paul Adobe", + "adobe_user_email": "paul@example.com", + "adobe_account_name": "Paul Sandbox", + "adobe_account_id": "adobe-account-123", + }): + resp = client.get( + "/api/auth/adobe/callback?code=authcode123&state=expected-state", + cookies={"migrator_session": cookie}, + follow_redirects=False, + ) + + assert resp.status_code in (302, 307) + 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 + assert status_resp.json()["adobe_auth_mode"] == "session_oauth" + + +def test_adobe_callback_requires_matching_state(): + cookie = create_test_session({"adobe_oauth_state": "expected-state"}) + resp = client.get( + "/api/auth/adobe/callback?code=authcode123&state=wrong-state", + cookies={"migrator_session": cookie}, + ) + assert resp.status_code == 400 + assert "invalid oauth state" in resp.json()["error"] + + def test_adobe_exchange_rejects_missing_code(): """POST /api/auth/adobe/exchange with no code in URL → 400.""" resp = client.post( @@ -274,7 +318,7 @@ def test_disconnect_clears_token(): # Connect Adobe via exchange connect_resp = client.post( "/api/auth/adobe/exchange", - json={"redirect_url": "https://localhost:8080/callback?code=abc"}, + json={"redirect_url": "http://localhost:8000/api/auth/adobe/callback?code=abc"}, ) session_cookie = connect_resp.cookies["migrator_session"] diff --git a/web/routers/auth.py b/web/routers/auth.py index 7e96838..fb93a7e 100644 --- a/web/routers/auth.py +++ b/web/routers/auth.py @@ -3,13 +3,9 @@ web/routers/auth.py ------------------- OAuth endpoints for Adobe Sign and DocuSign. -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 server-side session keyed by a signed browser cookie. +Both providers now support standard browser redirect callbacks handled directly by +this server. Tokens are stored in a server-side session keyed by a signed browser +cookie. """ import secrets @@ -35,12 +31,44 @@ from web.session import get_session, save_session, session_public_view 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" +def _adobe_redirect_uri() -> str: + return settings.adobe_redirect_uri + + +def _build_adobe_authorization_url(state: str) -> str: + return ( + f"{_ADOBE_AUTH_URL}" + f"?response_type=code" + f"&client_id={settings.adobe_client_id}" + f"&redirect_uri={_adobe_redirect_uri()}" + f"&scope=library_read:self+library_write:self+user_read:self" + f"&state={state}" + ) + + +async def _refresh_adobe_session_token(refresh_token: str) -> dict: + async with httpx.AsyncClient() as client: + resp = await client.post( + _ADOBE_TOKEN_URL, + data={ + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "client_id": settings.adobe_client_id, + "client_secret": settings.adobe_client_secret, + }, + ) + if not resp.is_success: + raise RuntimeError(f"Adobe Sign token refresh failed ({resp.status_code})") + token_data = resp.json() + if "error" in token_data: + raise RuntimeError(token_data.get("error_description", token_data["error"])) + return token_data + + async def _fetch_adobe_profile(access_token: str) -> dict: """ Best-effort Adobe Sign profile lookup used only for nicer UI labels. @@ -122,23 +150,18 @@ def auth_status(request: Request): # --------------------------------------------------------------------------- -# Adobe Sign — manual paste flow (matches CLI behaviour) +# Adobe Sign — OAuth Authorization Code Grant # --------------------------------------------------------------------------- @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={_ADOBE_REDIRECT_URI}" - f"&scope=library_read:self+library_write:self+user_read:self" - ) - return {"url": _ADOBE_AUTH_URL + params} +def adobe_auth_url(request: Request): + session = get_session(request) + state = secrets.token_urlsafe(24) + session["adobe_oauth_state"] = state + session["adobe_auth_mode"] = "authorization_pending" + response = JSONResponse({"url": _build_adobe_authorization_url(state)}) + save_session(response, session) + return response class AdobeExchangeRequest(BaseModel): @@ -176,7 +199,7 @@ async def adobe_exchange(body: AdobeExchangeRequest, request: Request): "grant_type": "authorization_code", "client_id": settings.adobe_client_id, "client_secret": settings.adobe_client_secret, - "redirect_uri": _ADOBE_REDIRECT_URI, + "redirect_uri": _adobe_redirect_uri(), "code": code, }, ) @@ -206,46 +229,135 @@ async def adobe_exchange(body: AdobeExchangeRequest, request: Request): @router.get("/adobe/connect") -async def adobe_connect_env(request: Request): +async def adobe_connect(request: Request, force_oauth: bool = False): """ - 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. + Obtain an Adobe Sign access token for this browser session. + If session/env tokens are unavailable or force_oauth=true, return an + authorization URL so the frontend can start a normal OAuth flow. """ - import sys - import os - sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src")) - from adobe_api import _refresh_access_token + session = get_session(request) + token = session.get("adobe_access_token") + refresh_token = session.get("adobe_refresh_token") - token = os.getenv("ADOBE_ACCESS_TOKEN") - refresh_token = os.getenv("ADOBE_REFRESH_TOKEN") + if not force_oauth and not token and not refresh_token: + import sys + import os + sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src")) + from adobe_api import _refresh_access_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, - ) + env_token = os.getenv("ADOBE_ACCESS_TOKEN") + env_refresh_token = os.getenv("ADOBE_REFRESH_TOKEN") + if env_token or env_refresh_token: + token = env_token + refresh_token = env_refresh_token + if refresh_token: + try: + token = _refresh_access_token() + except RuntimeError as e: + return JSONResponse({"error": str(e)}, status_code=500) + session["adobe_access_token"] = token + session["adobe_refresh_token"] = refresh_token + session["adobe_auth_mode"] = "shared_env" + session = await _merge_adobe_profile(session, token) + log_event( + request, + session, + "adobe_connected", + {"auth_mode": "shared_env", "source": "server_env"}, + ) + response = JSONResponse({"connected": True}) + save_session(response, session) + return response - # Always refresh to ensure the token is fresh (access tokens expire in ~1h) - if refresh_token: + if not force_oauth and not token and refresh_token: try: - token = _refresh_access_token() + token_data = await _refresh_adobe_session_token(refresh_token) except RuntimeError as e: return JSONResponse({"error": str(e)}, status_code=500) + token = token_data.get("access_token") + session["adobe_access_token"] = token + session["adobe_refresh_token"] = token_data.get("refresh_token", refresh_token) + session["adobe_auth_mode"] = "session_oauth" + session = await _merge_adobe_profile(session, token) + log_event( + request, + session, + "adobe_connected", + {"auth_mode": "session_oauth", "source": "session_refresh"}, + ) + response = JSONResponse({"connected": True}) + save_session(response, session) + return response + + if not force_oauth and token: + response = JSONResponse({"connected": True}) + save_session(response, session) + return response + + state = secrets.token_urlsafe(24) + session["adobe_oauth_state"] = state + session["adobe_auth_mode"] = "authorization_pending" + authorization_url = _build_adobe_authorization_url(state) + log_event( + request, + session, + "adobe_authorization_requested", + {"auth_mode": "authorization_pending"}, + ) + response = JSONResponse( + { + "connected": False, + "authorization_required": True, + "authorization_url": authorization_url, + } + ) + save_session(response, session) + return response + + +@router.get("/adobe/callback") +async def adobe_callback(request: Request, code: str = "", state: str = ""): + """Handle Adobe Sign OAuth redirect callback.""" + if not code: + return JSONResponse({"error": "missing code"}, status_code=400) session = get_session(request) - session["adobe_access_token"] = token - session["adobe_refresh_token"] = refresh_token - session["adobe_auth_mode"] = "shared_env" - session = await _merge_adobe_profile(session, token) + expected_state = session.get("adobe_oauth_state") + if not expected_state or state != expected_state: + return JSONResponse({"error": "invalid oauth state"}, status_code=400) + + async with httpx.AsyncClient() as client: + resp = await client.post( + _ADOBE_TOKEN_URL, + data={ + "grant_type": "authorization_code", + "client_id": settings.adobe_client_id, + "client_secret": settings.adobe_client_secret, + "redirect_uri": _adobe_redirect_uri(), + "code": code, + }, + ) + + if not resp.is_success: + 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["adobe_access_token"] = token_data.get("access_token") + session["adobe_refresh_token"] = token_data.get("refresh_token") + session["adobe_auth_mode"] = "session_oauth" + session.pop("adobe_oauth_state", None) + session = await _merge_adobe_profile(session, session["adobe_access_token"]) log_event( request, session, "adobe_connected", - {"auth_mode": "shared_env", "source": "server_env"}, + {"auth_mode": "session_oauth", "source": "browser_callback"}, ) - response = JSONResponse({"connected": True}) + response = RedirectResponse("/#/settings") save_session(response, session) return response @@ -260,6 +372,7 @@ def adobe_disconnect(request: Request): session.pop("adobe_user_email", None) session.pop("adobe_account_name", None) session.pop("adobe_account_id", None) + session.pop("adobe_oauth_state", None) session["adobe_auth_mode"] = "disconnected" log_event( request, diff --git a/web/static/js/api.js b/web/static/js/api.js index e0487b1..bd774de 100644 --- a/web/static/js/api.js +++ b/web/static/js/api.js @@ -26,8 +26,8 @@ export const api = { status() { return GET('/api/auth/status'); }, - connectAdobe() { - return GET('/api/auth/adobe/connect'); + connectAdobe(forceOauth = false) { + return GET(`/api/auth/adobe/connect${forceOauth ? '?force_oauth=true' : ''}`); }, adobeUrl() { return GET('/api/auth/adobe/url'); diff --git a/web/static/js/auth.js b/web/static/js/auth.js index 732c79a..b119f06 100644 --- a/web/static/js/auth.js +++ b/web/static/js/auth.js @@ -76,7 +76,7 @@ async function onClickAdobe() { if (state.auth.adobe) { showAuthMenu('adobe', 'chip-adobe'); } else { - await connectAdobeEnv(); + await connectAdobe(); } } @@ -135,30 +135,29 @@ export async function switchAccount(platform) { return; } await disconnectPlatform(platform, { silent: true, skipRefresh: true }); - showToast('Adobe Sign disconnected. Reconnect to continue.', 'info'); - await connectAdobeEnv(); + showToast('Starting a fresh Adobe Sign authorization…', 'info'); + await connectAdobe(true); } -async function connectAdobeEnv() { +async function connectAdobe(forceOauth = false) { closeAuthMenu(); setChipConnecting('chip-adobe'); try { - const data = await api.auth.connectAdobe(); + const data = await api.auth.connectAdobe(forceOauth); if (data.connected) { setState('auth', { ...state.auth, adobe: true }); renderAuthChips(); const { refreshTemplates } = await import('./templates.js'); refreshTemplates(); - } else if (data.error && data.error.includes('No Adobe Sign credentials')) { - renderAuthChips(); - showAdobeOAuthDialog(); + } else if (data.authorization_required && data.authorization_url) { + window.location.href = data.authorization_url; } else { renderAuthChips(); showToast('Adobe Sign error: ' + (data.error || 'unknown'), 'error'); } } catch (e) { renderAuthChips(); - showAdobeOAuthDialog(); + showToast('Adobe Sign connection failed: ' + e.message, 'error'); } } @@ -378,73 +377,6 @@ async function selectDocusignAccount(accountId, errorEl = null) { } } -// ── Adobe OAuth dialog (manual redirect URL paste) ───────────────────────── - -async function showAdobeOAuthDialog() { - const { url } = await api.auth.adobeUrl().catch(() => ({ url: '#' })); - - const existing = document.getElementById('adobe-auth-dialog'); - if (existing) existing.remove(); - - const dialog = document.createElement('div'); - dialog.id = 'adobe-auth-dialog'; - dialog.innerHTML = ` - - - `; - document.body.appendChild(dialog); - - document.getElementById('adobe-dialog-close').onclick = () => dialog.remove(); - document.getElementById('adobe-dialog-cancel').onclick = () => dialog.remove(); - document.getElementById('adobe-dialog-submit').onclick = () => submitAdobeCode(dialog); - document.getElementById('adobe-redirect-input').addEventListener('keydown', e => { - if (e.key === 'Enter') submitAdobeCode(dialog); - }); -} - -async function submitAdobeCode(dialog) { - const url = document.getElementById('adobe-redirect-input').value.trim(); - if (!url) return; - - const submitBtn = document.getElementById('adobe-dialog-submit'); - const errorEl = document.getElementById('adobe-dialog-error'); - submitBtn.disabled = true; - submitBtn.textContent = 'Connecting…'; - errorEl.textContent = ''; - - try { - await api.auth.exchangeAdobe(url); - dialog.remove(); - setState('auth', { ...state.auth, adobe: true }); - renderAuthChips(); - const { refreshTemplates } = await import('./templates.js'); - refreshTemplates(); - } catch (e) { - errorEl.textContent = e.data?.error || e.message || 'Connection failed.'; - submitBtn.disabled = false; - submitBtn.textContent = 'Connect'; - } -} - // ── Toast notification ───────────────────────────────────────────────────── export function showToast(message, type = 'info') {