Compare commits

..

No commits in common. "b8dbad73ac54b0bcb465516c5048e1fc0f1a4f4f" and "dd7a041820f28f97fce37caacbfb27c4498badc6" have entirely different histories.

8 changed files with 205 additions and 167 deletions

View File

@ -19,13 +19,21 @@ ADOBE_SIGN_BASE_URL=https://api.eu2.adobesign.com/api/rest/v6
# Integration key (client ID) from the DocuSign developer console # Integration key (client ID) from the DocuSign developer console
DOCUSIGN_CLIENT_ID=your-integration-key DOCUSIGN_CLIENT_ID=your-integration-key
# Client secret used for the Auth Code Grant and refresh-token exchange # Client secret — only needed for the one-time Auth Code Grant consent flow
DOCUSIGN_CLIENT_SECRET=your-client-secret DOCUSIGN_CLIENT_SECRET=your-client-secret
# GUID of the DocuSign user to impersonate via JWT grant
# Found in the DocuSign admin UI under Users → user details
DOCUSIGN_USER_ID=your-docusign-user-guid
# Account ID of the target DocuSign account # Account ID of the target DocuSign account
# Found in the DocuSign admin UI under Settings → Account Profile # Found in the DocuSign admin UI under Settings → Account Profile
DOCUSIGN_ACCOUNT_ID=your-docusign-account-id DOCUSIGN_ACCOUNT_ID=your-docusign-account-id
# Path to the RSA private key file used for JWT signing
# Generate a keypair in the DocuSign developer console and save the private key here
DOCUSIGN_PRIVATE_KEY_PATH=/path/to/private.key
# OAuth auth server — use account-d.docusign.com for sandbox, account.docusign.com for production # OAuth auth server — use account-d.docusign.com for sandbox, account.docusign.com for production
DOCUSIGN_AUTH_SERVER=account-d.docusign.com DOCUSIGN_AUTH_SERVER=account-d.docusign.com
@ -34,11 +42,10 @@ DOCUSIGN_AUTH_SERVER=account-d.docusign.com
# Production: https://na3.docusign.net/restapi (replace na3 with your shard) # Production: https://na3.docusign.net/restapi (replace na3 with your shard)
DOCUSIGN_BASE_URL=https://demo.docusign.net/restapi DOCUSIGN_BASE_URL=https://demo.docusign.net/restapi
# Redirect URI registered in your DocuSign app # Redirect URI registered in your DocuSign app (used only during one-time consent flow)
DOCUSIGN_REDIRECT_URI=http://localhost:8000/api/auth/docusign/callback DOCUSIGN_REDIRECT_URI=http://localhost:8080/callback
# Auto-written by src/docusign_auth.py after the initial authorization flow. # Auto-written by src/docusign_auth.py to cache the JWT access token.
# Leave blank; they will be populated automatically. # Leave blank; they will be populated automatically.
DOCUSIGN_ACCESS_TOKEN= DOCUSIGN_ACCESS_TOKEN=
DOCUSIGN_REFRESH_TOKEN=
DOCUSIGN_TOKEN_EXPIRY= DOCUSIGN_TOKEN_EXPIRY=

View File

@ -10,7 +10,7 @@ It downloads templates via the Adobe Sign API, converts them to DocuSign format,
1. **Authenticates** with Adobe Sign via OAuth (one-time browser flow, tokens saved to `.env`) 1. **Authenticates** with Adobe Sign via OAuth (one-time browser flow, tokens saved to `.env`)
2. **Downloads** templates — PDF, metadata, and form field definitions 2. **Downloads** templates — PDF, metadata, and form field definitions
3. **Converts** each template to a DocuSign `envelopeTemplate` JSON, mapping all field types, coordinates, recipient roles, and conditional field logic 3. **Converts** each template to a DocuSign `envelopeTemplate` JSON, mapping all field types, coordinates, recipient roles, and conditional field logic
4. **Authenticates** with DocuSign via OAuth Authorization Code Grant (one-time browser login, then refresh-token based) 4. **Authenticates** with DocuSign via JWT grant (one-time browser consent, then fully automated)
5. **Uploads** the converted template to DocuSign via the REST API 5. **Uploads** the converted template to DocuSign via the REST API
--- ---
@ -19,7 +19,7 @@ It downloads templates via the Adobe Sign API, converts them to DocuSign format,
- Python 3.10+ - Python 3.10+
- An Adobe Sign OAuth app (EU2 shard) with scopes: `library_read:self library_write:self user_read:self` - An Adobe Sign OAuth app (EU2 shard) with scopes: `library_read:self library_write:self user_read:self`
- A DocuSign developer account with an OAuth app client ID and client secret - A DocuSign developer account with an integration key and RSA keypair
--- ---
@ -46,13 +46,14 @@ python3 src/adobe_auth.py
Opens a browser. After authorizing, paste the redirect URL back into the terminal. Opens a browser. After authorizing, paste the redirect URL back into the terminal.
Tokens are saved to `.env` and auto-refreshed on subsequent runs. Tokens are saved to `.env` and auto-refreshed on subsequent runs.
**4. Authorize DocuSign** (one-time per user): **4. Grant consent for DocuSign** (one-time per user):
```bash ```bash
python3 src/docusign_auth.py --authorize python3 src/docusign_auth.py --consent
``` ```
Opens a browser for the DocuSign OAuth screen. After approving, paste the Opens a browser for the DocuSign OAuth consent screen. After approving, paste the
redirect URL back into the terminal. The app stores both access and refresh redirect URL back into the terminal. This grants the `impersonation` scope required
tokens in `.env`, and later API calls refresh access tokens automatically. for JWT grant. After this runs once, all subsequent API calls use JWT automatically —
no further browser interaction needed.
--- ---
@ -273,7 +274,7 @@ src/
adobe_api.py # Adobe Sign API client (auto token refresh) adobe_api.py # Adobe Sign API client (auto token refresh)
download_templates.py # List and download templates from Adobe Sign download_templates.py # List and download templates from Adobe Sign
compose_docusign_template.py # Core conversion: Adobe Sign → DocuSign JSON compose_docusign_template.py # Core conversion: Adobe Sign → DocuSign JSON
docusign_auth.py # DocuSign auth-code + refresh-token helper docusign_auth.py # DocuSign JWT auth + one-time consent flow
upload_docusign_template.py # Upsert upload: PUT if exists, POST if not upload_docusign_template.py # Upsert upload: PUT if exists, POST if not
migrate_template.py # End-to-end CLI runner (download → convert → upload) migrate_template.py # End-to-end CLI runner (download → convert → upload)

View File

@ -1,23 +1,31 @@
""" """
docusign_auth.py docusign_auth.py
---------------- ----------------
Handles DocuSign OAuth using the Authorization Code Grant. Handles DocuSign authentication for the migration toolkit.
Two flows:
JWT Grant service-to-service, no user interaction. Used for all
normal API calls. Requires consent to have been granted.
Auth Code Grant browser-based OAuth flow. Run once with --consent to
grant the app the 'impersonation' scope it needs for JWT.
Usage: Usage:
python3 src/docusign_auth.py --authorize # one-time browser login python3 src/docusign_auth.py --consent # one-time browser consent
python3 src/docusign_auth.py # print a fresh access token python3 src/docusign_auth.py # print a fresh access token (smoke test)
Required .env keys: Required .env keys:
DOCUSIGN_CLIENT_ID DOCUSIGN_CLIENT_ID Integration key from your DocuSign app
DOCUSIGN_CLIENT_SECRET DOCUSIGN_USER_ID GUID of the DocuSign user the app will act as
DOCUSIGN_AUTH_SERVER DOCUSIGN_ACCOUNT_ID Your DocuSign account ID
DOCUSIGN_REDIRECT_URI DOCUSIGN_PRIVATE_KEY_PATH Path to your RSA private key (.pem or .key)
DOCUSIGN_BASE_URL DOCUSIGN_AUTH_SERVER account-d.docusign.com (sandbox)
or account.docusign.com (production)
DOCUSIGN_BASE_URL https://demo.docusign.net/restapi (sandbox)
or https://na3.docusign.net/restapi (prod, check your account)
Auto-written to .env after authorization: For --consent only:
DOCUSIGN_ACCESS_TOKEN DOCUSIGN_CLIENT_SECRET OAuth client secret
DOCUSIGN_REFRESH_TOKEN DOCUSIGN_REDIRECT_URI Must match your app config (default: http://localhost:8080/callback)
DOCUSIGN_TOKEN_EXPIRY
""" """
import argparse import argparse
@ -25,36 +33,89 @@ import os
import sys import sys
import time import time
import webbrowser import webbrowser
from urllib.parse import parse_qs, urlencode, urlparse from urllib.parse import urlencode, urlparse, parse_qs
import jwt
import requests import requests
from dotenv import load_dotenv, set_key from dotenv import load_dotenv, set_key
load_dotenv() load_dotenv()
ENV_FILE = os.path.join(os.path.dirname(__file__), "..", ".env") ENV_FILE = os.path.join(os.path.dirname(__file__), "..", ".env")
TOKEN_EXPIRY_BUFFER = 120 TOKEN_EXPIRY_BUFFER = 120 # refresh token 2 minutes before it expires
DOCUSIGN_SCOPE = "signature"
def _required_env(name: str) -> str: # ---------------------------------------------------------------------------
value = os.getenv(name) # JWT Grant
if not value: # ---------------------------------------------------------------------------
raise RuntimeError(f"{name} must be set in .env")
return value def _load_private_key():
key_path = os.getenv("DOCUSIGN_PRIVATE_KEY_PATH")
if not key_path:
raise RuntimeError("DOCUSIGN_PRIVATE_KEY_PATH is not set in .env")
key_path = os.path.expanduser(key_path)
if not os.path.exists(key_path):
raise RuntimeError(f"Private key not found: {key_path}")
with open(key_path, "r") as f:
return f.read()
def _auth_server() -> str: def _request_jwt_token():
return os.getenv("DOCUSIGN_AUTH_SERVER", "account-d.docusign.com") """Exchange a JWT assertion for a DocuSign access token."""
client_id = os.getenv("DOCUSIGN_CLIENT_ID")
user_id = os.getenv("DOCUSIGN_USER_ID")
auth_server = os.getenv("DOCUSIGN_AUTH_SERVER", "account-d.docusign.com")
private_key = _load_private_key()
if not all([client_id, user_id]):
raise RuntimeError("DOCUSIGN_CLIENT_ID and DOCUSIGN_USER_ID must be set in .env")
now = int(time.time())
payload = {
"iss": client_id,
"sub": user_id,
"aud": auth_server,
"iat": now,
"exp": now + 3600,
"scope": "signature impersonation",
}
assertion = jwt.encode(payload, private_key, algorithm="RS256")
# PyJWT >= 2.0 returns str; older versions return bytes
if isinstance(assertion, bytes):
assertion = assertion.decode("utf-8")
resp = requests.post(
f"https://{auth_server}/oauth/token",
data={
"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
"assertion": assertion,
},
)
if resp.status_code == 400 and "consent_required" in resp.text:
raise RuntimeError(
"Consent not yet granted for this app/user combination.\n"
"Run: python3 src/docusign_auth.py --consent\n"
"Then retry."
)
resp.raise_for_status()
return resp.json()
def _redirect_uri() -> str: def get_access_token() -> str:
return os.getenv("DOCUSIGN_REDIRECT_URI", "http://localhost:8000/api/auth/docusign/callback") """
Return a valid DocuSign access token, refreshing via JWT grant if needed.
Caches the token in .env to avoid unnecessary round-trips.
"""
cached_token = os.getenv("DOCUSIGN_ACCESS_TOKEN")
cached_expiry = os.getenv("DOCUSIGN_TOKEN_EXPIRY")
if cached_token and cached_expiry:
if int(time.time()) < int(cached_expiry) - TOKEN_EXPIRY_BUFFER:
return cached_token
def _persist_token_data(token_data: dict) -> str: token_data = _request_jwt_token()
access_token = token_data["access_token"] access_token = token_data["access_token"]
refresh_token = token_data.get("refresh_token")
expiry = int(time.time()) + int(token_data.get("expires_in", 3600)) expiry = int(time.time()) + int(token_data.get("expires_in", 3600))
abs_env = os.path.abspath(ENV_FILE) abs_env = os.path.abspath(ENV_FILE)
@ -63,34 +124,42 @@ def _persist_token_data(token_data: dict) -> str:
os.environ["DOCUSIGN_ACCESS_TOKEN"] = access_token os.environ["DOCUSIGN_ACCESS_TOKEN"] = access_token
os.environ["DOCUSIGN_TOKEN_EXPIRY"] = str(expiry) os.environ["DOCUSIGN_TOKEN_EXPIRY"] = str(expiry)
if refresh_token:
set_key(abs_env, "DOCUSIGN_REFRESH_TOKEN", refresh_token)
os.environ["DOCUSIGN_REFRESH_TOKEN"] = refresh_token
return access_token return access_token
def build_authorization_url() -> str: # ---------------------------------------------------------------------------
client_id = _required_env("DOCUSIGN_CLIENT_ID") # Auth Code Grant — consent flow
# ---------------------------------------------------------------------------
def _build_consent_url():
client_id = os.getenv("DOCUSIGN_CLIENT_ID")
auth_server = os.getenv("DOCUSIGN_AUTH_SERVER", "account-d.docusign.com")
redirect_uri = os.getenv("DOCUSIGN_REDIRECT_URI", "http://localhost:8080/callback")
params = { params = {
"response_type": "code", "response_type": "code",
"scope": DOCUSIGN_SCOPE, "scope": "signature impersonation",
"client_id": client_id, "client_id": client_id,
"redirect_uri": _redirect_uri(), "redirect_uri": redirect_uri,
} }
return f"https://{_auth_server()}/oauth/auth?{urlencode(params)}" return f"https://{auth_server}/oauth/auth?{urlencode(params)}"
def exchange_code_for_token(code: str) -> dict: def _exchange_code(code: str):
client_id = _required_env("DOCUSIGN_CLIENT_ID") client_id = os.getenv("DOCUSIGN_CLIENT_ID")
client_secret = _required_env("DOCUSIGN_CLIENT_SECRET") client_secret = os.getenv("DOCUSIGN_CLIENT_SECRET")
auth_server = os.getenv("DOCUSIGN_AUTH_SERVER", "account-d.docusign.com")
redirect_uri = os.getenv("DOCUSIGN_REDIRECT_URI", "http://localhost:8080/callback")
if not client_secret:
raise RuntimeError("DOCUSIGN_CLIENT_SECRET must be set in .env for the consent flow")
resp = requests.post( resp = requests.post(
f"https://{_auth_server()}/oauth/token", f"https://{auth_server}/oauth/token",
data={ data={
"grant_type": "authorization_code", "grant_type": "authorization_code",
"code": code, "code": code,
"redirect_uri": _redirect_uri(), "redirect_uri": redirect_uri,
}, },
auth=(client_id, client_secret), auth=(client_id, client_secret),
) )
@ -98,53 +167,18 @@ def exchange_code_for_token(code: str) -> dict:
return resp.json() return resp.json()
def refresh_access_token(refresh_token: str | None = None) -> dict: def run_consent_flow():
client_id = _required_env("DOCUSIGN_CLIENT_ID") """
client_secret = _required_env("DOCUSIGN_CLIENT_SECRET") Open a browser for the user to grant consent, then exchange the code for
refresh_token = refresh_token or os.getenv("DOCUSIGN_REFRESH_TOKEN") an initial access token. After this succeeds, JWT grant will work.
"""
if not refresh_token: url = _build_consent_url()
raise RuntimeError( print("\nOpening browser for DocuSign consent...")
"No DocuSign refresh token found. Run: python3 src/docusign_auth.py --authorize"
)
resp = requests.post(
f"https://{_auth_server()}/oauth/token",
data={
"grant_type": "refresh_token",
"refresh_token": refresh_token,
},
auth=(client_id, client_secret),
)
resp.raise_for_status()
return resp.json()
def save_code_token_exchange(code: str) -> str:
token_data = exchange_code_for_token(code)
return _persist_token_data(token_data)
def get_access_token() -> str:
"""Return a valid DocuSign access token using cached or refreshed OAuth tokens."""
cached_token = os.getenv("DOCUSIGN_ACCESS_TOKEN")
cached_expiry = os.getenv("DOCUSIGN_TOKEN_EXPIRY")
if cached_token and cached_expiry:
if int(time.time()) < int(cached_expiry) - TOKEN_EXPIRY_BUFFER:
return cached_token
token_data = refresh_access_token()
return _persist_token_data(token_data)
def run_authorize_flow():
url = build_authorization_url()
print("\nOpening browser for DocuSign authorization...")
print(f"\nIf the browser doesn't open, go to:\n{url}\n") print(f"\nIf the browser doesn't open, go to:\n{url}\n")
webbrowser.open(url) webbrowser.open(url)
print("Log in, approve access, then paste the full redirect URL here.\n") print("Log in and click Allow. The browser will redirect to your redirect URI")
print("(the page may show an error — that's fine). Copy the full URL and paste it here.\n")
redirected_url = input("Paste the redirect URL: ").strip() redirected_url = input("Paste the redirect URL: ").strip()
parsed = urlparse(redirected_url) parsed = urlparse(redirected_url)
@ -155,32 +189,37 @@ def run_authorize_flow():
print(f"ERROR: {error}") print(f"ERROR: {error}")
sys.exit(1) sys.exit(1)
code_list = params.get("code") if "code" not in params:
if not code_list:
print("ERROR: No authorization code found in the URL.") print("ERROR: No authorization code found in the URL.")
sys.exit(1) sys.exit(1)
code = params["code"][0]
print("Exchanging code for token...") print("Exchanging code for token...")
save_code_token_exchange(code_list[0]) token_data = _exchange_code(code)
print("Authorization complete. Access and refresh tokens were saved to .env.")
access_token = token_data["access_token"]
expiry = int(time.time()) + int(token_data.get("expires_in", 3600))
abs_env = os.path.abspath(ENV_FILE)
set_key(abs_env, "DOCUSIGN_ACCESS_TOKEN", access_token)
set_key(abs_env, "DOCUSIGN_TOKEN_EXPIRY", str(expiry))
print("Consent granted and token saved.")
print("JWT grant will now work for future calls — you won't need to run --consent again.")
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main(): def main():
parser = argparse.ArgumentParser(description="DocuSign authentication helper") parser = argparse.ArgumentParser(description="DocuSign authentication helper")
parser.add_argument( parser.add_argument("--consent", action="store_true",
"--authorize", help="Run the Auth Code Grant consent flow (required once per user/app)")
action="store_true",
help="Run the Auth Code Grant flow and store refresh credentials",
)
parser.add_argument(
"--consent",
action="store_true",
help="Backward-compatible alias for --authorize",
)
args = parser.parse_args() args = parser.parse_args()
if args.authorize or args.consent: if args.consent:
run_authorize_flow() run_consent_flow()
else: else:
token = get_access_token() token = get_access_token()
print(f"Access token: {token[:20]}... (valid for ~1 hour)") print(f"Access token: {token[:20]}... (valid for ~1 hour)")

View File

@ -2,7 +2,7 @@
upload_docusign_template.py upload_docusign_template.py
--------------------------- ---------------------------
Uploads a DocuSign template JSON file to DocuSign via the REST API. Uploads a DocuSign template JSON file to DocuSign via the REST API.
Authenticates using DocuSign OAuth tokens stored in .env. Authenticates using JWT grant (no Node.js dependency required).
By default uses upsert: if a template with the same name already exists, By default uses upsert: if a template with the same name already exists,
the most recently modified one is updated (PUT). Use --force-create to the most recently modified one is updated (PUT). Use --force-create to
@ -13,12 +13,12 @@ Usage:
python3 src/upload_docusign_template.py --file <path> --force-create python3 src/upload_docusign_template.py --file <path> --force-create
First-time setup: First-time setup:
python3 src/docusign_auth.py --authorize # authorize once python3 src/docusign_auth.py --consent # grant consent once
python3 src/upload_docusign_template.py --file <path> python3 src/upload_docusign_template.py --file <path>
Required .env keys (see docusign_auth.py for full list): Required .env keys (see docusign_auth.py for full list):
DOCUSIGN_CLIENT_ID, DOCUSIGN_CLIENT_SECRET, DOCUSIGN_ACCOUNT_ID, DOCUSIGN_CLIENT_ID, DOCUSIGN_USER_ID, DOCUSIGN_ACCOUNT_ID,
DOCUSIGN_AUTH_SERVER, DOCUSIGN_BASE_URL, DOCUSIGN_REDIRECT_URI DOCUSIGN_PRIVATE_KEY_PATH, DOCUSIGN_AUTH_SERVER, DOCUSIGN_BASE_URL
""" """
import argparse import argparse

View File

@ -33,7 +33,7 @@ Then open [http://localhost:8000](http://localhost:8000).
- [ ] Top bar shows two disconnected chips (red dot): "Adobe Sign" and "DocuSign" - [ ] Top bar shows two disconnected chips (red dot): "Adobe Sign" and "DocuSign"
- [ ] Click "Adobe Sign" chip → connects via `.env` refresh token → chip turns green - [ ] Click "Adobe Sign" chip → connects via `.env` refresh token → chip turns green
- [ ] Click "DocuSign" chip → redirects through OAuth if needed, then chip turns green - [ ] Click "DocuSign" chip → connects via JWT grant → chip turns green
- [ ] Disconnecting either chip → chip turns red → templates clear - [ ] Disconnecting either chip → chip turns red → templates clear
## 4. Templates View ## 4. Templates View

View File

@ -96,10 +96,11 @@ def test_adobe_exchange_rejects_missing_code():
def test_docusign_connect_stores_token(): def test_docusign_connect_stores_token():
"""GET /api/auth/docusign/connect uses cached OAuth credentials → session connected.""" """GET /api/auth/docusign/connect uses JWT grant from .env → session connected."""
from unittest.mock import patch from unittest.mock import patch
import web.routers.auth as auth_module
with patch("docusign_auth.get_access_token", return_value="ds-oauth-token"): with patch("docusign_auth.get_access_token", return_value="ds-jwt-token"):
resp = client.get("/api/auth/docusign/connect") resp = client.get("/api/auth/docusign/connect")
assert resp.status_code == 200 assert resp.status_code == 200
@ -112,19 +113,6 @@ def test_docusign_connect_stores_token():
assert status_resp.json()["docusign"] is True assert status_resp.json()["docusign"] is True
def test_docusign_connect_requests_authorization_when_refresh_token_missing():
"""GET /api/auth/docusign/connect returns an auth URL when first-time authorization is needed."""
from unittest.mock import patch
with patch("docusign_auth.get_access_token", side_effect=RuntimeError("No DocuSign refresh token found")), \
patch("docusign_auth.build_authorization_url", return_value="https://example.com/oauth"):
resp = client.get("/api/auth/docusign/connect")
assert resp.status_code == 200
assert resp.json()["authorization_required"] is True
assert resp.json()["authorization_url"] == "https://example.com/oauth"
@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."""

View File

@ -164,33 +164,24 @@ def adobe_disconnect(request: Request):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# DocuSign — Auth Code Grant + refresh token # DocuSign — JWT grant (.env) or OAuth redirect
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@router.get("/docusign/connect") @router.get("/docusign/connect")
def docusign_connect(request: Request): def docusign_connect(request: Request):
""" """
Obtain a DocuSign access token from cached OAuth credentials in .env. Obtain a DocuSign access token via JWT grant using the credentials already
If the app has not been authorized yet, return the authorization URL so the in .env (DOCUSIGN_CLIENT_ID, DOCUSIGN_USER_ID, DOCUSIGN_PRIVATE_KEY_PATH).
frontend can start the browser flow. No browser sign-in needed consent was already granted via the CLI setup.
""" """
import sys import sys
import os import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src")) sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src"))
from docusign_auth import build_authorization_url, get_access_token from docusign_auth import get_access_token
try: try:
token = get_access_token() token = get_access_token()
except RuntimeError as e: except RuntimeError as e:
if "refresh token" in str(e).lower():
return JSONResponse(
{
"connected": False,
"authorization_required": True,
"authorization_url": build_authorization_url(),
},
status_code=200,
)
return JSONResponse({"error": str(e)}, status_code=500) return JSONResponse({"error": str(e)}, status_code=500)
session = get_session(request) session = get_session(request)
@ -203,32 +194,46 @@ def docusign_connect(request: Request):
@router.get("/docusign/start") @router.get("/docusign/start")
def docusign_start(): def docusign_start():
"""Redirect to the DocuSign OAuth authorization screen.""" """Redirect to DocuSign OAuth (alternative to JWT grant)."""
import sys import base64 as _b64
import os params = (
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src")) f"?response_type=code"
from docusign_auth import build_authorization_url f"&scope=signature"
f"&client_id={settings.docusign_client_id}"
return RedirectResponse(build_authorization_url()) f"&redirect_uri={settings.docusign_redirect_uri}"
)
return RedirectResponse(f"https://{settings.docusign_auth_server}/oauth/auth" + params)
@router.get("/docusign/callback") @router.get("/docusign/callback")
async def docusign_callback(request: Request, code: str = ""): async def docusign_callback(request: Request, code: str = ""):
"""Handle DocuSign OAuth redirect callback.""" """Handle DocuSign OAuth redirect callback."""
import sys import base64
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src"))
from docusign_auth import save_code_token_exchange
if not code: if not code:
return JSONResponse({"error": "missing code"}, status_code=400) return JSONResponse({"error": "missing code"}, status_code=400)
session = get_session(request)
try:
session["docusign_access_token"] = save_code_token_exchange(code)
except Exception as e:
return JSONResponse({"error": "token exchange failed", "detail": str(e)}, status_code=502)
session["docusign_refresh_token"] = os.getenv("DOCUSIGN_REFRESH_TOKEN") credentials = base64.b64encode(
f"{settings.docusign_client_id}:{settings.docusign_client_secret}".encode()
).decode()
async with httpx.AsyncClient() as client:
resp = await client.post(
f"https://{settings.docusign_auth_server}/oauth/token",
headers={"Authorization": f"Basic {credentials}"},
data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": settings.docusign_redirect_uri,
},
)
if not resp.is_success:
return JSONResponse({"error": "token exchange failed", "detail": resp.text}, status_code=502)
token_data = resp.json()
session = get_session(request)
session["docusign_access_token"] = token_data.get("access_token")
session["docusign_refresh_token"] = token_data.get("refresh_token")
response = RedirectResponse("/") response = RedirectResponse("/")
save_session(response, session) save_session(response, session)

View File

@ -100,8 +100,6 @@ async function connectDocusign() {
renderAuthChips(); renderAuthChips();
const { refreshTemplates } = await import('./templates.js'); const { refreshTemplates } = await import('./templates.js');
refreshTemplates(); refreshTemplates();
} else if (data.authorization_required && data.authorization_url) {
window.location.href = data.authorization_url;
} else { } else {
renderAuthChips(); renderAuthChips();
showToast('Docusign error: ' + (data.error || 'unknown'), 'error'); showToast('Docusign error: ' + (data.error || 'unknown'), 'error');