adobe-to-docusign-migrator/web/routers/auth.py

414 lines
15 KiB
Python

"""
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.
"""
import secrets
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.docusign_context import (
DocusignContextError,
account_picker_required,
clear_selected_account,
current_account,
fetch_userinfo,
merge_userinfo,
select_account,
)
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"
async def _fetch_adobe_profile(access_token: str) -> dict:
"""
Best-effort Adobe Sign profile lookup used only for nicer UI labels.
This should never block a successful connection if Adobe returns sparse data.
"""
try:
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{settings.adobe_sign_base_url}/users/me",
headers={"Authorization": f"Bearer {access_token}"},
)
except Exception:
return {}
if not resp.is_success:
return {}
data = resp.json() if resp.content else {}
if not isinstance(data, dict):
return {}
company = data.get("company") or {}
account_name = (
company.get("name")
or data.get("companyName")
or data.get("accountName")
or data.get("name")
)
account_id = (
company.get("id")
or data.get("companyId")
or data.get("accountId")
)
return {
"adobe_user_name": data.get("name"),
"adobe_user_email": data.get("email"),
"adobe_account_name": account_name,
"adobe_account_id": account_id,
}
async def _merge_adobe_profile(session: dict, access_token: str) -> dict:
profile = await _fetch_adobe_profile(access_token)
if not profile:
return session
updated = dict(session)
for key, value in profile.items():
if value:
updated[key] = value
return updated
# ---------------------------------------------------------------------------
# Status
# ---------------------------------------------------------------------------
@router.get("/status")
def auth_status(request: Request):
"""Returns which platforms the current session is connected to."""
session = get_session(request)
docusign_account = None
try:
docusign_account = current_account(session) if session.get("docusign_access_token") else None
except DocusignContextError:
docusign_account = None
return {
**session_public_view(session),
"adobe_label": session.get("adobe_account_name") or session.get("adobe_user_name") or "Adobe Sign",
"docusign_label": session.get("docusign_user_name") or "Docusign",
"adobe_account_name": session.get("adobe_account_name"),
"adobe_account_id": session.get("adobe_account_id"),
"docusign_account_id": (docusign_account or {}).get("account_id"),
"docusign_account_name": (docusign_account or {}).get("account_name"),
"base_url": (docusign_account or {}).get("base_url", settings.docusign_base_url),
"docusign_account_selection_required": account_picker_required(session),
}
# ---------------------------------------------------------------------------
# Adobe Sign — manual paste flow (matches CLI behaviour)
# ---------------------------------------------------------------------------
@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}
class AdobeExchangeRequest(BaseModel):
redirect_url: str
class DocusignAccountSelectRequest(BaseModel):
account_id: 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(
_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 = get_session(request)
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 = await _merge_adobe_profile(session, session["adobe_access_token"])
response = JSONResponse({"connected": True})
save_session(response, session)
return response
@router.get("/adobe/connect")
async def adobe_connect_env(request: Request):
"""
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.
"""
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src"))
from adobe_api import _refresh_access_token
token = os.getenv("ADOBE_ACCESS_TOKEN")
refresh_token = os.getenv("ADOBE_REFRESH_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,
)
# Always refresh to ensure the token is fresh (access tokens expire in ~1h)
if refresh_token:
try:
token = _refresh_access_token()
except RuntimeError as e:
return JSONResponse({"error": str(e)}, status_code=500)
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)
response = JSONResponse({"connected": True})
save_session(response, session)
return response
@router.get("/adobe/disconnect")
def adobe_disconnect(request: Request):
session = get_session(request)
session.pop("adobe_access_token", None)
session.pop("adobe_refresh_token", None)
session.pop("adobe_user_name", None)
session.pop("adobe_user_email", None)
session.pop("adobe_account_name", None)
session.pop("adobe_account_id", None)
session["adobe_auth_mode"] = "disconnected"
response = JSONResponse({"disconnected": "adobe"})
save_session(response, session)
return response
# ---------------------------------------------------------------------------
# DocuSign — Auth Code Grant + refresh token
# ---------------------------------------------------------------------------
@router.get("/docusign/connect")
async def docusign_connect(request: Request):
"""
Obtain a DocuSign access token from the current browser session.
If the session has not been authorized yet, return an authorization URL so
the frontend can start an isolated OAuth flow for this tester.
"""
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src"))
from docusign_auth import (
build_authorization_url,
refresh_access_token,
session_from_token_data,
session_has_valid_access_token,
)
session = get_session(request)
try:
if session_has_valid_access_token(session):
token = session["docusign_access_token"]
elif session.get("docusign_refresh_token"):
token_data = refresh_access_token(session["docusign_refresh_token"])
session = session_from_token_data(token_data, session)
token = session["docusign_access_token"]
else:
raise RuntimeError("No DocuSign refresh token found for this browser session.")
except RuntimeError as e:
if "refresh token" in str(e).lower():
state = secrets.token_urlsafe(24)
session["docusign_oauth_state"] = state
session["docusign_auth_mode"] = "authorization_pending"
response = JSONResponse(
{
"connected": False,
"authorization_required": True,
"authorization_url": build_authorization_url(state=state),
},
status_code=200,
)
save_session(response, session)
return response
return JSONResponse({"error": str(e)}, status_code=500)
if not session.get("docusign_accounts"):
try:
session = merge_userinfo(session, await fetch_userinfo(token))
except DocusignContextError as e:
return JSONResponse({"error": str(e)}, status_code=e.status_code)
session["docusign_access_token"] = token
session["docusign_auth_mode"] = "session_oauth"
response = JSONResponse({
"connected": True,
"account_selection_required": account_picker_required(session),
})
save_session(response, session)
return response
@router.get("/docusign/start")
def docusign_start(request: Request):
"""Redirect to the DocuSign OAuth authorization screen for this browser session."""
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src"))
from docusign_auth import build_authorization_url
session = get_session(request)
state = secrets.token_urlsafe(24)
session["docusign_oauth_state"] = state
session["docusign_auth_mode"] = "authorization_pending"
response = RedirectResponse(build_authorization_url(state=state))
save_session(response, session)
return response
@router.get("/docusign/callback")
async def docusign_callback(request: Request, code: str = "", state: str = ""):
"""Handle DocuSign OAuth redirect callback."""
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src"))
from docusign_auth import exchange_code_for_token, session_from_token_data
if not code:
return JSONResponse({"error": "missing code"}, status_code=400)
session = get_session(request)
expected_state = session.get("docusign_oauth_state")
if not expected_state or state != expected_state:
return JSONResponse({"error": "invalid oauth state"}, status_code=400)
try:
token_data = exchange_code_for_token(code)
session = session_from_token_data(token_data, session)
session = merge_userinfo(session, await fetch_userinfo(session["docusign_access_token"]))
except Exception as e:
return JSONResponse({"error": "token exchange failed", "detail": str(e)}, status_code=502)
session.pop("docusign_oauth_state", None)
session["docusign_auth_mode"] = "session_oauth"
response = RedirectResponse("/#/settings" if account_picker_required(session) else "/")
save_session(response, session)
return response
@router.get("/docusign/accounts")
def docusign_accounts(request: Request):
session = get_session(request)
if not session.get("docusign_access_token"):
return JSONResponse({"error": "not authenticated to DocuSign"}, status_code=401)
return {
"accounts": session.get("docusign_accounts", []),
"selected_account_id": session.get("docusign_selected_account_id"),
"selection_required": account_picker_required(session),
}
@router.post("/docusign/account-select")
def docusign_account_select(body: DocusignAccountSelectRequest, request: Request):
session = get_session(request)
if not session.get("docusign_access_token"):
return JSONResponse({"error": "not authenticated to DocuSign"}, status_code=401)
try:
session = select_account(session, body.account_id)
except DocusignContextError as e:
return JSONResponse({"error": str(e), "code": e.code}, status_code=e.status_code)
response = JSONResponse(
{
"selected_account_id": session.get("docusign_selected_account_id"),
"selected_account_name": session.get("docusign_selected_account_name"),
}
)
save_session(response, session)
return response
@router.get("/docusign/disconnect")
def docusign_disconnect(request: Request):
session = get_session(request)
session.pop("docusign_access_token", None)
session.pop("docusign_refresh_token", None)
session.pop("docusign_token_expiry", None)
session.pop("docusign_oauth_state", None)
session.pop("docusign_user_name", None)
session.pop("docusign_user_email", None)
session.pop("docusign_accounts", None)
session.pop("docusign_accounts_count", None)
clear_selected_account(session)
session["docusign_auth_mode"] = "disconnected"
response = JSONResponse({"disconnected": "docusign"})
save_session(response, session)
return response