From 93b6ad248ae4c9f29a816401c94f99a42cb9d38b Mon Sep 17 00:00:00 2001 From: Paul Huliganga Date: Wed, 15 Apr 2026 19:45:23 -0400 Subject: [PATCH] feat: DocuSign JWT auth and pure-Python template upload client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- requirements.txt | 2 + src/docusign_auth.py | 229 ++++++++++++++++++++++++++++++++ src/upload_docusign_template.py | 96 +++++++++++++ 3 files changed, 327 insertions(+) create mode 100644 src/docusign_auth.py create mode 100644 src/upload_docusign_template.py diff --git a/requirements.txt b/requirements.txt index ed185af..0c8889c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,5 @@ requests python-dotenv pydantic +PyJWT>=2.0 +cryptography diff --git a/src/docusign_auth.py b/src/docusign_auth.py new file mode 100644 index 0000000..a287fbe --- /dev/null +++ b/src/docusign_auth.py @@ -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() diff --git a/src/upload_docusign_template.py b/src/upload_docusign_template.py new file mode 100644 index 0000000..b74ac5e --- /dev/null +++ b/src/upload_docusign_template.py @@ -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//docusign-template.json + +First-time setup: + python3 src/docusign_auth.py --consent # grant consent once + python3 src/upload_docusign_template.py --file + +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()