adobe-to-docusign-migrator/src/docusign_auth.py

230 lines
7.9 KiB
Python

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