feat: DocuSign JWT auth and pure-Python template upload client

docusign_auth.py — authentication helper supporting two flows:
- JWT Grant: service-to-service token generation using an RSA private key;
  caches token + expiry in .env to avoid redundant round-trips
- Auth Code Grant (--consent): one-time browser flow to grant the app the
  'impersonation' scope required for JWT; must be run once per user/app before
  JWT will work

upload_docusign_template.py — posts a docusign-template.json to the DocuSign
Templates REST API (v2.1). No Node.js dependency. Retries once on 401.

requirements.txt — adds PyJWT>=2.0 and cryptography for RSA key handling.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Paul Huliganga 2026-04-15 19:45:23 -04:00
parent 76568672d7
commit 93b6ad248a
3 changed files with 327 additions and 0 deletions

View File

@ -1,3 +1,5 @@
requests requests
python-dotenv python-dotenv
pydantic pydantic
PyJWT>=2.0
cryptography

229
src/docusign_auth.py Normal file
View File

@ -0,0 +1,229 @@
"""
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.
Usage:
python3 src/docusign_auth.py --consent # one-time browser consent
python3 src/docusign_auth.py # print a fresh access token (smoke test)
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)
For --consent only:
DOCUSIGN_CLIENT_SECRET OAuth client secret
DOCUSIGN_REDIRECT_URI Must match your app config (default: http://localhost:8080/callback)
"""
import argparse
import os
import sys
import time
import webbrowser
from urllib.parse import urlencode, urlparse, parse_qs
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
# ---------------------------------------------------------------------------
# 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 _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 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")
if cached_token and cached_expiry:
if int(time.time()) < int(cached_expiry) - TOKEN_EXPIRY_BUFFER:
return cached_token
token_data = _request_jwt_token()
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))
os.environ["DOCUSIGN_ACCESS_TOKEN"] = access_token
os.environ["DOCUSIGN_TOKEN_EXPIRY"] = str(expiry)
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")
params = {
"response_type": "code",
"scope": "signature impersonation",
"client_id": client_id,
"redirect_uri": redirect_uri,
}
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")
resp = requests.post(
f"https://{auth_server}/oauth/token",
data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
},
auth=(client_id, client_secret),
)
resp.raise_for_status()
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...")
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")
redirected_url = input("Paste the redirect URL: ").strip()
parsed = urlparse(redirected_url)
params = parse_qs(parsed.query)
if "error" in params:
error = params.get("error_description", params.get("error", ["unknown"]))[0]
print(f"ERROR: {error}")
sys.exit(1)
if "code" not in params:
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)
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)")
args = parser.parse_args()
if args.consent:
run_consent_flow()
else:
token = get_access_token()
print(f"Access token: {token[:20]}... (valid for ~1 hour)")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,96 @@
"""
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).
Usage:
python3 src/upload_docusign_template.py --file migration-output/<name>/docusign-template.json
First-time setup:
python3 src/docusign_auth.py --consent # grant consent 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
"""
import argparse
import json
import os
import sys
import requests
from dotenv import load_dotenv
load_dotenv()
sys.path.insert(0, os.path.dirname(__file__))
from docusign_auth import get_access_token
def upload_template(file_path: str) -> str:
"""
POST a template JSON file to the DocuSign Templates API.
Returns the created templateId.
"""
if not os.path.exists(file_path):
print(f"ERROR: File not found: {file_path}")
sys.exit(1)
with open(file_path) as f:
template = json.load(f)
account_id = os.getenv("DOCUSIGN_ACCOUNT_ID")
base_url = os.getenv("DOCUSIGN_BASE_URL", "https://demo.docusign.net/restapi")
if not account_id:
print("ERROR: DOCUSIGN_ACCOUNT_ID must be set in .env")
sys.exit(1)
token = get_access_token()
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
"Accept": "application/json",
}
url = f"{base_url}/v2.1/accounts/{account_id}/templates"
print(f"Uploading '{template.get('name', file_path)}' to DocuSign...")
resp = requests.post(url, headers=headers, json=template)
if resp.status_code == 401:
# Token may have just expired — clear cache and retry once
os.environ.pop("DOCUSIGN_ACCESS_TOKEN", None)
os.environ.pop("DOCUSIGN_TOKEN_EXPIRY", None)
token = get_access_token()
headers["Authorization"] = f"Bearer {token}"
resp = requests.post(url, headers=headers, json=template)
if not resp.ok:
print(f"ERROR: Upload failed ({resp.status_code})")
print(resp.text)
sys.exit(1)
result = resp.json()
template_id = result.get("templateId")
print(f"Template created: {template_id}")
return template_id
def main():
parser = argparse.ArgumentParser(
description="Upload a DocuSign template JSON to your DocuSign account"
)
parser.add_argument(
"--file", required=True,
help="Path to the docusign-template.json file to upload"
)
args = parser.parse_args()
upload_template(args.file)
if __name__ == "__main__":
main()