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:
parent
76568672d7
commit
93b6ad248a
|
|
@ -1,3 +1,5 @@
|
|||
requests
|
||||
python-dotenv
|
||||
pydantic
|
||||
PyJWT>=2.0
|
||||
cryptography
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
Loading…
Reference in New Issue