Use Adobe OAuth callback flow in web UI

This commit is contained in:
Paul Huliganga 2026-04-22 12:00:17 -04:00
parent 8f0b14bc62
commit e19bd68ebd
6 changed files with 226 additions and 134 deletions

View File

@ -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 ────────────────────────────────────────────────────────────────

View File

@ -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:

View File

@ -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"]

View File

@ -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,

View File

@ -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');

View File

@ -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 = `
<div class="modal-backdrop"></div>
<div class="modal-box">
<div class="modal-header">
<span class="modal-title">Connect Adobe Sign</span>
<button class="btn btn-ghost btn-icon" id="adobe-dialog-close"></button>
</div>
<div class="modal-body">
<ol style="padding-left:18px;line-height:1.8;margin-bottom:14px;font-size:13px">
<li><a href="${escHtml(url)}" target="_blank" rel="noopener" style="color:var(--cobalt)">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" class="form-input"
placeholder="https://localhost:8080/callback?code=…" />
<div id="adobe-dialog-error" style="color:var(--error);font-size:12px;min-height:18px;margin-top:6px"></div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" id="adobe-dialog-cancel">Cancel</button>
<button class="btn btn-primary" id="adobe-dialog-submit">Connect</button>
</div>
</div>
`;
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') {