230 lines
7.9 KiB
Python
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()
|