Compare commits

...

3 Commits

Author SHA1 Message Date
Paul Huliganga b8dbad73ac Merge branch 'ui-redesign' 2026-04-21 20:31:13 -04:00
Paul Huliganga 3b27a0fd5b Update DocuSign auth docs and coverage 2026-04-21 16:26:13 -04:00
Paul Huliganga 3be3903986 Switch DocuSign auth to authorization code flow 2026-04-21 16:25:56 -04:00
8 changed files with 167 additions and 205 deletions

View File

@ -19,21 +19,13 @@ ADOBE_SIGN_BASE_URL=https://api.eu2.adobesign.com/api/rest/v6
# Integration key (client ID) from the DocuSign developer console
DOCUSIGN_CLIENT_ID=your-integration-key
# Client secret — only needed for the one-time Auth Code Grant consent flow
# Client secret used for the Auth Code Grant and refresh-token exchange
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
# Found in the DocuSign admin UI under Settings → Account Profile
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
DOCUSIGN_AUTH_SERVER=account-d.docusign.com
@ -42,10 +34,11 @@ DOCUSIGN_AUTH_SERVER=account-d.docusign.com
# Production: https://na3.docusign.net/restapi (replace na3 with your shard)
DOCUSIGN_BASE_URL=https://demo.docusign.net/restapi
# Redirect URI registered in your DocuSign app (used only during one-time consent flow)
DOCUSIGN_REDIRECT_URI=http://localhost:8080/callback
# Redirect URI registered in your DocuSign app
DOCUSIGN_REDIRECT_URI=http://localhost:8000/api/auth/docusign/callback
# Auto-written by src/docusign_auth.py to cache the JWT access token.
# Auto-written by src/docusign_auth.py after the initial authorization flow.
# Leave blank; they will be populated automatically.
DOCUSIGN_ACCESS_TOKEN=
DOCUSIGN_REFRESH_TOKEN=
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`)
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
4. **Authenticates** with DocuSign via JWT grant (one-time browser consent, then fully automated)
4. **Authenticates** with DocuSign via OAuth Authorization Code Grant (one-time browser login, then refresh-token based)
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+
- An Adobe Sign OAuth app (EU2 shard) with scopes: `library_read:self library_write:self user_read:self`
- A DocuSign developer account with an integration key and RSA keypair
- A DocuSign developer account with an OAuth app client ID and client secret
---
@ -46,14 +46,13 @@ python3 src/adobe_auth.py
Opens a browser. After authorizing, paste the redirect URL back into the terminal.
Tokens are saved to `.env` and auto-refreshed on subsequent runs.
**4. Grant consent for DocuSign** (one-time per user):
**4. Authorize DocuSign** (one-time per user):
```bash
python3 src/docusign_auth.py --consent
python3 src/docusign_auth.py --authorize
```
Opens a browser for the DocuSign OAuth consent screen. After approving, paste the
redirect URL back into the terminal. This grants the `impersonation` scope required
for JWT grant. After this runs once, all subsequent API calls use JWT automatically —
no further browser interaction needed.
Opens a browser for the DocuSign OAuth screen. After approving, paste the
redirect URL back into the terminal. The app stores both access and refresh
tokens in `.env`, and later API calls refresh access tokens automatically.
---
@ -274,7 +273,7 @@ src/
adobe_api.py # Adobe Sign API client (auto token refresh)
download_templates.py # List and download templates from Adobe Sign
compose_docusign_template.py # Core conversion: Adobe Sign → DocuSign JSON
docusign_auth.py # DocuSign JWT auth + one-time consent flow
docusign_auth.py # DocuSign auth-code + refresh-token helper
upload_docusign_template.py # Upsert upload: PUT if exists, POST if not
migrate_template.py # End-to-end CLI runner (download → convert → upload)

View File

@ -1,31 +1,23 @@
"""
docusign_auth.py
----------------
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.
Handles DocuSign OAuth using the Authorization Code Grant.
Usage:
python3 src/docusign_auth.py --consent # one-time browser consent
python3 src/docusign_auth.py # print a fresh access token (smoke test)
python3 src/docusign_auth.py --authorize # one-time browser login
python3 src/docusign_auth.py # print a fresh access token
Required .env keys:
DOCUSIGN_CLIENT_ID Integration key from your DocuSign app
DOCUSIGN_USER_ID GUID of the DocuSign user the app will act as
DOCUSIGN_ACCOUNT_ID Your DocuSign account ID
DOCUSIGN_PRIVATE_KEY_PATH Path to your RSA private key (.pem or .key)
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)
DOCUSIGN_CLIENT_ID
DOCUSIGN_CLIENT_SECRET
DOCUSIGN_AUTH_SERVER
DOCUSIGN_REDIRECT_URI
DOCUSIGN_BASE_URL
For --consent only:
DOCUSIGN_CLIENT_SECRET OAuth client secret
DOCUSIGN_REDIRECT_URI Must match your app config (default: http://localhost:8080/callback)
Auto-written to .env after authorization:
DOCUSIGN_ACCESS_TOKEN
DOCUSIGN_REFRESH_TOKEN
DOCUSIGN_TOKEN_EXPIRY
"""
import argparse
@ -33,89 +25,36 @@ import os
import sys
import time
import webbrowser
from urllib.parse import urlencode, urlparse, parse_qs
from urllib.parse import parse_qs, urlencode, urlparse
import jwt
import requests
from dotenv import load_dotenv, set_key
load_dotenv()
ENV_FILE = os.path.join(os.path.dirname(__file__), "..", ".env")
TOKEN_EXPIRY_BUFFER = 120 # refresh token 2 minutes before it expires
TOKEN_EXPIRY_BUFFER = 120
DOCUSIGN_SCOPE = "signature"
# ---------------------------------------------------------------------------
# JWT Grant
# ---------------------------------------------------------------------------
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 _required_env(name: str) -> str:
value = os.getenv(name)
if not value:
raise RuntimeError(f"{name} must be set in .env")
return value
def _request_jwt_token():
"""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 _auth_server() -> str:
return os.getenv("DOCUSIGN_AUTH_SERVER", "account-d.docusign.com")
def get_access_token() -> str:
"""
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")
def _redirect_uri() -> str:
return os.getenv("DOCUSIGN_REDIRECT_URI", "http://localhost:8000/api/auth/docusign/callback")
if cached_token and cached_expiry:
if int(time.time()) < int(cached_expiry) - TOKEN_EXPIRY_BUFFER:
return cached_token
token_data = _request_jwt_token()
def _persist_token_data(token_data: dict) -> str:
access_token = token_data["access_token"]
refresh_token = token_data.get("refresh_token")
expiry = int(time.time()) + int(token_data.get("expires_in", 3600))
abs_env = os.path.abspath(ENV_FILE)
@ -124,42 +63,34 @@ def get_access_token() -> str:
os.environ["DOCUSIGN_ACCESS_TOKEN"] = access_token
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
# ---------------------------------------------------------------------------
# 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")
def build_authorization_url() -> str:
client_id = _required_env("DOCUSIGN_CLIENT_ID")
params = {
"response_type": "code",
"scope": "signature impersonation",
"scope": DOCUSIGN_SCOPE,
"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(code: str):
client_id = os.getenv("DOCUSIGN_CLIENT_ID")
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")
def exchange_code_for_token(code: str) -> dict:
client_id = _required_env("DOCUSIGN_CLIENT_ID")
client_secret = _required_env("DOCUSIGN_CLIENT_SECRET")
resp = requests.post(
f"https://{auth_server}/oauth/token",
f"https://{_auth_server()}/oauth/token",
data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
"redirect_uri": _redirect_uri(),
},
auth=(client_id, client_secret),
)
@ -167,18 +98,53 @@ def _exchange_code(code: str):
return resp.json()
def run_consent_flow():
"""
Open a browser for the user to grant consent, then exchange the code for
an initial access token. After this succeeds, JWT grant will work.
"""
url = _build_consent_url()
print("\nOpening browser for DocuSign consent...")
def refresh_access_token(refresh_token: str | None = None) -> dict:
client_id = _required_env("DOCUSIGN_CLIENT_ID")
client_secret = _required_env("DOCUSIGN_CLIENT_SECRET")
refresh_token = refresh_token or os.getenv("DOCUSIGN_REFRESH_TOKEN")
if not refresh_token:
raise RuntimeError(
"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")
webbrowser.open(url)
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")
print("Log in, approve access, then paste the full redirect URL here.\n")
redirected_url = input("Paste the redirect URL: ").strip()
parsed = urlparse(redirected_url)
@ -189,37 +155,32 @@ def run_consent_flow():
print(f"ERROR: {error}")
sys.exit(1)
if "code" not in params:
code_list = params.get("code")
if not code_list:
print("ERROR: No authorization code found in the URL.")
sys.exit(1)
code = params["code"][0]
print("Exchanging code for token...")
token_data = _exchange_code(code)
save_code_token_exchange(code_list[0])
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():
parser = argparse.ArgumentParser(description="DocuSign authentication helper")
parser.add_argument("--consent", action="store_true",
help="Run the Auth Code Grant consent flow (required once per user/app)")
parser.add_argument(
"--authorize",
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()
if args.consent:
run_consent_flow()
if args.authorize or args.consent:
run_authorize_flow()
else:
token = get_access_token()
print(f"Access token: {token[:20]}... (valid for ~1 hour)")

View File

@ -2,7 +2,7 @@
upload_docusign_template.py
---------------------------
Uploads a DocuSign template JSON file to DocuSign via the REST API.
Authenticates using JWT grant (no Node.js dependency required).
Authenticates using DocuSign OAuth tokens stored in .env.
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
@ -13,12 +13,12 @@ Usage:
python3 src/upload_docusign_template.py --file <path> --force-create
First-time setup:
python3 src/docusign_auth.py --consent # grant consent once
python3 src/docusign_auth.py --authorize # authorize once
python3 src/upload_docusign_template.py --file <path>
Required .env keys (see docusign_auth.py for full list):
DOCUSIGN_CLIENT_ID, DOCUSIGN_USER_ID, DOCUSIGN_ACCOUNT_ID,
DOCUSIGN_PRIVATE_KEY_PATH, DOCUSIGN_AUTH_SERVER, DOCUSIGN_BASE_URL
DOCUSIGN_CLIENT_ID, DOCUSIGN_CLIENT_SECRET, DOCUSIGN_ACCOUNT_ID,
DOCUSIGN_AUTH_SERVER, DOCUSIGN_BASE_URL, DOCUSIGN_REDIRECT_URI
"""
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"
- [ ] Click "Adobe Sign" chip → connects via `.env` refresh token → chip turns green
- [ ] Click "DocuSign" chip → connects via JWT grant → chip turns green
- [ ] Click "DocuSign" chip → redirects through OAuth if needed, then chip turns green
- [ ] Disconnecting either chip → chip turns red → templates clear
## 4. Templates View

View File

@ -96,11 +96,10 @@ def test_adobe_exchange_rejects_missing_code():
def test_docusign_connect_stores_token():
"""GET /api/auth/docusign/connect uses JWT grant from .env → session connected."""
"""GET /api/auth/docusign/connect uses cached OAuth credentials → session connected."""
from unittest.mock import patch
import web.routers.auth as auth_module
with patch("docusign_auth.get_access_token", return_value="ds-jwt-token"):
with patch("docusign_auth.get_access_token", return_value="ds-oauth-token"):
resp = client.get("/api/auth/docusign/connect")
assert resp.status_code == 200
@ -113,6 +112,19 @@ def test_docusign_connect_stores_token():
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
def test_disconnect_clears_token():
"""After disconnect, status shows platform as disconnected."""

View File

@ -164,24 +164,33 @@ def adobe_disconnect(request: Request):
# ---------------------------------------------------------------------------
# DocuSign — JWT grant (.env) or OAuth redirect
# DocuSign — Auth Code Grant + refresh token
# ---------------------------------------------------------------------------
@router.get("/docusign/connect")
def docusign_connect(request: Request):
"""
Obtain a DocuSign access token via JWT grant using the credentials already
in .env (DOCUSIGN_CLIENT_ID, DOCUSIGN_USER_ID, DOCUSIGN_PRIVATE_KEY_PATH).
No browser sign-in needed consent was already granted via the CLI setup.
Obtain a DocuSign access token from cached OAuth credentials in .env.
If the app has not been authorized yet, return the authorization URL so the
frontend can start the browser flow.
"""
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src"))
from docusign_auth import get_access_token
from docusign_auth import build_authorization_url, get_access_token
try:
token = get_access_token()
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)
session = get_session(request)
@ -194,46 +203,32 @@ def docusign_connect(request: Request):
@router.get("/docusign/start")
def docusign_start():
"""Redirect to DocuSign OAuth (alternative to JWT grant)."""
import base64 as _b64
params = (
f"?response_type=code"
f"&scope=signature"
f"&client_id={settings.docusign_client_id}"
f"&redirect_uri={settings.docusign_redirect_uri}"
)
return RedirectResponse(f"https://{settings.docusign_auth_server}/oauth/auth" + params)
"""Redirect to the DocuSign OAuth authorization screen."""
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src"))
from docusign_auth import build_authorization_url
return RedirectResponse(build_authorization_url())
@router.get("/docusign/callback")
async def docusign_callback(request: Request, code: str = ""):
"""Handle DocuSign OAuth redirect callback."""
import base64
import sys
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:
return JSONResponse({"error": "missing code"}, status_code=400)
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")
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")
response = RedirectResponse("/")
save_session(response, session)

View File

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