""" 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()