Merge branch 'ui-redesign'
This commit is contained in:
commit
b8dbad73ac
17
.env-sample
17
.env-sample
|
|
@ -19,21 +19,13 @@ ADOBE_SIGN_BASE_URL=https://api.eu2.adobesign.com/api/rest/v6
|
||||||
# Integration key (client ID) from the DocuSign developer console
|
# Integration key (client ID) from the DocuSign developer console
|
||||||
DOCUSIGN_CLIENT_ID=your-integration-key
|
DOCUSIGN_CLIENT_ID=your-integration-key
|
||||||
|
|
||||||
# Client secret — only needed for the one-time Auth Code Grant consent flow
|
# Client secret used for the Auth Code Grant and refresh-token exchange
|
||||||
DOCUSIGN_CLIENT_SECRET=your-client-secret
|
DOCUSIGN_CLIENT_SECRET=your-client-secret
|
||||||
|
|
||||||
# GUID of the DocuSign user to impersonate via JWT grant
|
|
||||||
# Found in the DocuSign admin UI under Users → user details
|
|
||||||
DOCUSIGN_USER_ID=your-docusign-user-guid
|
|
||||||
|
|
||||||
# Account ID of the target DocuSign account
|
# Account ID of the target DocuSign account
|
||||||
# Found in the DocuSign admin UI under Settings → Account Profile
|
# Found in the DocuSign admin UI under Settings → Account Profile
|
||||||
DOCUSIGN_ACCOUNT_ID=your-docusign-account-id
|
DOCUSIGN_ACCOUNT_ID=your-docusign-account-id
|
||||||
|
|
||||||
# Path to the RSA private key file used for JWT signing
|
|
||||||
# Generate a keypair in the DocuSign developer console and save the private key here
|
|
||||||
DOCUSIGN_PRIVATE_KEY_PATH=/path/to/private.key
|
|
||||||
|
|
||||||
# OAuth auth server — use account-d.docusign.com for sandbox, account.docusign.com for production
|
# OAuth auth server — use account-d.docusign.com for sandbox, account.docusign.com for production
|
||||||
DOCUSIGN_AUTH_SERVER=account-d.docusign.com
|
DOCUSIGN_AUTH_SERVER=account-d.docusign.com
|
||||||
|
|
||||||
|
|
@ -42,10 +34,11 @@ DOCUSIGN_AUTH_SERVER=account-d.docusign.com
|
||||||
# Production: https://na3.docusign.net/restapi (replace na3 with your shard)
|
# Production: https://na3.docusign.net/restapi (replace na3 with your shard)
|
||||||
DOCUSIGN_BASE_URL=https://demo.docusign.net/restapi
|
DOCUSIGN_BASE_URL=https://demo.docusign.net/restapi
|
||||||
|
|
||||||
# Redirect URI registered in your DocuSign app (used only during one-time consent flow)
|
# Redirect URI registered in your DocuSign app
|
||||||
DOCUSIGN_REDIRECT_URI=http://localhost:8080/callback
|
DOCUSIGN_REDIRECT_URI=http://localhost:8000/api/auth/docusign/callback
|
||||||
|
|
||||||
# Auto-written by src/docusign_auth.py to cache the JWT access token.
|
# Auto-written by src/docusign_auth.py after the initial authorization flow.
|
||||||
# Leave blank; they will be populated automatically.
|
# Leave blank; they will be populated automatically.
|
||||||
DOCUSIGN_ACCESS_TOKEN=
|
DOCUSIGN_ACCESS_TOKEN=
|
||||||
|
DOCUSIGN_REFRESH_TOKEN=
|
||||||
DOCUSIGN_TOKEN_EXPIRY=
|
DOCUSIGN_TOKEN_EXPIRY=
|
||||||
|
|
|
||||||
17
README.md
17
README.md
|
|
@ -10,7 +10,7 @@ It downloads templates via the Adobe Sign API, converts them to DocuSign format,
|
||||||
1. **Authenticates** with Adobe Sign via OAuth (one-time browser flow, tokens saved to `.env`)
|
1. **Authenticates** with Adobe Sign via OAuth (one-time browser flow, tokens saved to `.env`)
|
||||||
2. **Downloads** templates — PDF, metadata, and form field definitions
|
2. **Downloads** templates — PDF, metadata, and form field definitions
|
||||||
3. **Converts** each template to a DocuSign `envelopeTemplate` JSON, mapping all field types, coordinates, recipient roles, and conditional field logic
|
3. **Converts** each template to a DocuSign `envelopeTemplate` JSON, mapping all field types, coordinates, recipient roles, and conditional field logic
|
||||||
4. **Authenticates** with DocuSign via JWT grant (one-time browser consent, then fully automated)
|
4. **Authenticates** with DocuSign via OAuth Authorization Code Grant (one-time browser login, then refresh-token based)
|
||||||
5. **Uploads** the converted template to DocuSign via the REST API
|
5. **Uploads** the converted template to DocuSign via the REST API
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -19,7 +19,7 @@ It downloads templates via the Adobe Sign API, converts them to DocuSign format,
|
||||||
|
|
||||||
- Python 3.10+
|
- Python 3.10+
|
||||||
- An Adobe Sign OAuth app (EU2 shard) with scopes: `library_read:self library_write:self user_read:self`
|
- An Adobe Sign OAuth app (EU2 shard) with scopes: `library_read:self library_write:self user_read:self`
|
||||||
- A DocuSign developer account with an integration key and RSA keypair
|
- A DocuSign developer account with an OAuth app client ID and client secret
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -46,14 +46,13 @@ python3 src/adobe_auth.py
|
||||||
Opens a browser. After authorizing, paste the redirect URL back into the terminal.
|
Opens a browser. After authorizing, paste the redirect URL back into the terminal.
|
||||||
Tokens are saved to `.env` and auto-refreshed on subsequent runs.
|
Tokens are saved to `.env` and auto-refreshed on subsequent runs.
|
||||||
|
|
||||||
**4. Grant consent for DocuSign** (one-time per user):
|
**4. Authorize DocuSign** (one-time per user):
|
||||||
```bash
|
```bash
|
||||||
python3 src/docusign_auth.py --consent
|
python3 src/docusign_auth.py --authorize
|
||||||
```
|
```
|
||||||
Opens a browser for the DocuSign OAuth consent screen. After approving, paste the
|
Opens a browser for the DocuSign OAuth screen. After approving, paste the
|
||||||
redirect URL back into the terminal. This grants the `impersonation` scope required
|
redirect URL back into the terminal. The app stores both access and refresh
|
||||||
for JWT grant. After this runs once, all subsequent API calls use JWT automatically —
|
tokens in `.env`, and later API calls refresh access tokens automatically.
|
||||||
no further browser interaction needed.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -274,7 +273,7 @@ src/
|
||||||
adobe_api.py # Adobe Sign API client (auto token refresh)
|
adobe_api.py # Adobe Sign API client (auto token refresh)
|
||||||
download_templates.py # List and download templates from Adobe Sign
|
download_templates.py # List and download templates from Adobe Sign
|
||||||
compose_docusign_template.py # Core conversion: Adobe Sign → DocuSign JSON
|
compose_docusign_template.py # Core conversion: Adobe Sign → DocuSign JSON
|
||||||
docusign_auth.py # DocuSign JWT auth + one-time consent flow
|
docusign_auth.py # DocuSign auth-code + refresh-token helper
|
||||||
upload_docusign_template.py # Upsert upload: PUT if exists, POST if not
|
upload_docusign_template.py # Upsert upload: PUT if exists, POST if not
|
||||||
migrate_template.py # End-to-end CLI runner (download → convert → upload)
|
migrate_template.py # End-to-end CLI runner (download → convert → upload)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,23 @@
|
||||||
"""
|
"""
|
||||||
docusign_auth.py
|
docusign_auth.py
|
||||||
----------------
|
----------------
|
||||||
Handles DocuSign authentication for the migration toolkit.
|
Handles DocuSign OAuth using the Authorization Code Grant.
|
||||||
|
|
||||||
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:
|
Usage:
|
||||||
python3 src/docusign_auth.py --consent # one-time browser consent
|
python3 src/docusign_auth.py --authorize # one-time browser login
|
||||||
python3 src/docusign_auth.py # print a fresh access token (smoke test)
|
python3 src/docusign_auth.py # print a fresh access token
|
||||||
|
|
||||||
Required .env keys:
|
Required .env keys:
|
||||||
DOCUSIGN_CLIENT_ID Integration key from your DocuSign app
|
DOCUSIGN_CLIENT_ID
|
||||||
DOCUSIGN_USER_ID GUID of the DocuSign user the app will act as
|
DOCUSIGN_CLIENT_SECRET
|
||||||
DOCUSIGN_ACCOUNT_ID Your DocuSign account ID
|
DOCUSIGN_AUTH_SERVER
|
||||||
DOCUSIGN_PRIVATE_KEY_PATH Path to your RSA private key (.pem or .key)
|
DOCUSIGN_REDIRECT_URI
|
||||||
DOCUSIGN_AUTH_SERVER account-d.docusign.com (sandbox)
|
DOCUSIGN_BASE_URL
|
||||||
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:
|
Auto-written to .env after authorization:
|
||||||
DOCUSIGN_CLIENT_SECRET OAuth client secret
|
DOCUSIGN_ACCESS_TOKEN
|
||||||
DOCUSIGN_REDIRECT_URI Must match your app config (default: http://localhost:8080/callback)
|
DOCUSIGN_REFRESH_TOKEN
|
||||||
|
DOCUSIGN_TOKEN_EXPIRY
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
|
@ -33,89 +25,36 @@ import os
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import webbrowser
|
import webbrowser
|
||||||
from urllib.parse import urlencode, urlparse, parse_qs
|
from urllib.parse import parse_qs, urlencode, urlparse
|
||||||
|
|
||||||
import jwt
|
|
||||||
import requests
|
import requests
|
||||||
from dotenv import load_dotenv, set_key
|
from dotenv import load_dotenv, set_key
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
ENV_FILE = os.path.join(os.path.dirname(__file__), "..", ".env")
|
ENV_FILE = os.path.join(os.path.dirname(__file__), "..", ".env")
|
||||||
TOKEN_EXPIRY_BUFFER = 120 # refresh token 2 minutes before it expires
|
TOKEN_EXPIRY_BUFFER = 120
|
||||||
|
DOCUSIGN_SCOPE = "signature"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
def _required_env(name: str) -> str:
|
||||||
# JWT Grant
|
value = os.getenv(name)
|
||||||
# ---------------------------------------------------------------------------
|
if not value:
|
||||||
|
raise RuntimeError(f"{name} must be set in .env")
|
||||||
def _load_private_key():
|
return value
|
||||||
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():
|
def _auth_server() -> str:
|
||||||
"""Exchange a JWT assertion for a DocuSign access token."""
|
return os.getenv("DOCUSIGN_AUTH_SERVER", "account-d.docusign.com")
|
||||||
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:
|
def _redirect_uri() -> str:
|
||||||
"""
|
return os.getenv("DOCUSIGN_REDIRECT_URI", "http://localhost:8000/api/auth/docusign/callback")
|
||||||
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()
|
def _persist_token_data(token_data: dict) -> str:
|
||||||
access_token = token_data["access_token"]
|
access_token = token_data["access_token"]
|
||||||
|
refresh_token = token_data.get("refresh_token")
|
||||||
expiry = int(time.time()) + int(token_data.get("expires_in", 3600))
|
expiry = int(time.time()) + int(token_data.get("expires_in", 3600))
|
||||||
|
|
||||||
abs_env = os.path.abspath(ENV_FILE)
|
abs_env = os.path.abspath(ENV_FILE)
|
||||||
|
|
@ -124,42 +63,34 @@ def get_access_token() -> str:
|
||||||
os.environ["DOCUSIGN_ACCESS_TOKEN"] = access_token
|
os.environ["DOCUSIGN_ACCESS_TOKEN"] = access_token
|
||||||
os.environ["DOCUSIGN_TOKEN_EXPIRY"] = str(expiry)
|
os.environ["DOCUSIGN_TOKEN_EXPIRY"] = str(expiry)
|
||||||
|
|
||||||
|
if refresh_token:
|
||||||
|
set_key(abs_env, "DOCUSIGN_REFRESH_TOKEN", refresh_token)
|
||||||
|
os.environ["DOCUSIGN_REFRESH_TOKEN"] = refresh_token
|
||||||
|
|
||||||
return access_token
|
return access_token
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
def build_authorization_url() -> str:
|
||||||
# Auth Code Grant — consent flow
|
client_id = _required_env("DOCUSIGN_CLIENT_ID")
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
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 = {
|
params = {
|
||||||
"response_type": "code",
|
"response_type": "code",
|
||||||
"scope": "signature impersonation",
|
"scope": DOCUSIGN_SCOPE,
|
||||||
"client_id": client_id,
|
"client_id": client_id,
|
||||||
"redirect_uri": redirect_uri,
|
"redirect_uri": _redirect_uri(),
|
||||||
}
|
}
|
||||||
return f"https://{auth_server}/oauth/auth?{urlencode(params)}"
|
return f"https://{_auth_server()}/oauth/auth?{urlencode(params)}"
|
||||||
|
|
||||||
|
|
||||||
def _exchange_code(code: str):
|
def exchange_code_for_token(code: str) -> dict:
|
||||||
client_id = os.getenv("DOCUSIGN_CLIENT_ID")
|
client_id = _required_env("DOCUSIGN_CLIENT_ID")
|
||||||
client_secret = os.getenv("DOCUSIGN_CLIENT_SECRET")
|
client_secret = _required_env("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(
|
resp = requests.post(
|
||||||
f"https://{auth_server}/oauth/token",
|
f"https://{_auth_server()}/oauth/token",
|
||||||
data={
|
data={
|
||||||
"grant_type": "authorization_code",
|
"grant_type": "authorization_code",
|
||||||
"code": code,
|
"code": code,
|
||||||
"redirect_uri": redirect_uri,
|
"redirect_uri": _redirect_uri(),
|
||||||
},
|
},
|
||||||
auth=(client_id, client_secret),
|
auth=(client_id, client_secret),
|
||||||
)
|
)
|
||||||
|
|
@ -167,18 +98,53 @@ def _exchange_code(code: str):
|
||||||
return resp.json()
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
def run_consent_flow():
|
def refresh_access_token(refresh_token: str | None = None) -> dict:
|
||||||
"""
|
client_id = _required_env("DOCUSIGN_CLIENT_ID")
|
||||||
Open a browser for the user to grant consent, then exchange the code for
|
client_secret = _required_env("DOCUSIGN_CLIENT_SECRET")
|
||||||
an initial access token. After this succeeds, JWT grant will work.
|
refresh_token = refresh_token or os.getenv("DOCUSIGN_REFRESH_TOKEN")
|
||||||
"""
|
|
||||||
url = _build_consent_url()
|
if not refresh_token:
|
||||||
print("\nOpening browser for DocuSign consent...")
|
raise RuntimeError(
|
||||||
|
"No DocuSign refresh token found. Run: python3 src/docusign_auth.py --authorize"
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = requests.post(
|
||||||
|
f"https://{_auth_server()}/oauth/token",
|
||||||
|
data={
|
||||||
|
"grant_type": "refresh_token",
|
||||||
|
"refresh_token": refresh_token,
|
||||||
|
},
|
||||||
|
auth=(client_id, client_secret),
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def save_code_token_exchange(code: str) -> str:
|
||||||
|
token_data = exchange_code_for_token(code)
|
||||||
|
return _persist_token_data(token_data)
|
||||||
|
|
||||||
|
|
||||||
|
def get_access_token() -> str:
|
||||||
|
"""Return a valid DocuSign access token using cached or refreshed OAuth tokens."""
|
||||||
|
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 = refresh_access_token()
|
||||||
|
return _persist_token_data(token_data)
|
||||||
|
|
||||||
|
|
||||||
|
def run_authorize_flow():
|
||||||
|
url = build_authorization_url()
|
||||||
|
print("\nOpening browser for DocuSign authorization...")
|
||||||
print(f"\nIf the browser doesn't open, go to:\n{url}\n")
|
print(f"\nIf the browser doesn't open, go to:\n{url}\n")
|
||||||
webbrowser.open(url)
|
webbrowser.open(url)
|
||||||
|
|
||||||
print("Log in and click Allow. The browser will redirect to your redirect URI")
|
print("Log in, approve access, then paste the full redirect URL here.\n")
|
||||||
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()
|
redirected_url = input("Paste the redirect URL: ").strip()
|
||||||
|
|
||||||
parsed = urlparse(redirected_url)
|
parsed = urlparse(redirected_url)
|
||||||
|
|
@ -189,37 +155,32 @@ def run_consent_flow():
|
||||||
print(f"ERROR: {error}")
|
print(f"ERROR: {error}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
if "code" not in params:
|
code_list = params.get("code")
|
||||||
|
if not code_list:
|
||||||
print("ERROR: No authorization code found in the URL.")
|
print("ERROR: No authorization code found in the URL.")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
code = params["code"][0]
|
|
||||||
print("Exchanging code for token...")
|
print("Exchanging code for token...")
|
||||||
token_data = _exchange_code(code)
|
save_code_token_exchange(code_list[0])
|
||||||
|
print("Authorization complete. Access and refresh tokens were saved to .env.")
|
||||||
|
|
||||||
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():
|
def main():
|
||||||
parser = argparse.ArgumentParser(description="DocuSign authentication helper")
|
parser = argparse.ArgumentParser(description="DocuSign authentication helper")
|
||||||
parser.add_argument("--consent", action="store_true",
|
parser.add_argument(
|
||||||
help="Run the Auth Code Grant consent flow (required once per user/app)")
|
"--authorize",
|
||||||
|
action="store_true",
|
||||||
|
help="Run the Auth Code Grant flow and store refresh credentials",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--consent",
|
||||||
|
action="store_true",
|
||||||
|
help="Backward-compatible alias for --authorize",
|
||||||
|
)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
if args.consent:
|
if args.authorize or args.consent:
|
||||||
run_consent_flow()
|
run_authorize_flow()
|
||||||
else:
|
else:
|
||||||
token = get_access_token()
|
token = get_access_token()
|
||||||
print(f"Access token: {token[:20]}... (valid for ~1 hour)")
|
print(f"Access token: {token[:20]}... (valid for ~1 hour)")
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
upload_docusign_template.py
|
upload_docusign_template.py
|
||||||
---------------------------
|
---------------------------
|
||||||
Uploads a DocuSign template JSON file to DocuSign via the REST API.
|
Uploads a DocuSign template JSON file to DocuSign via the REST API.
|
||||||
Authenticates using JWT grant (no Node.js dependency required).
|
Authenticates using DocuSign OAuth tokens stored in .env.
|
||||||
|
|
||||||
By default uses upsert: if a template with the same name already exists,
|
By default uses upsert: if a template with the same name already exists,
|
||||||
the most recently modified one is updated (PUT). Use --force-create to
|
the most recently modified one is updated (PUT). Use --force-create to
|
||||||
|
|
@ -13,12 +13,12 @@ Usage:
|
||||||
python3 src/upload_docusign_template.py --file <path> --force-create
|
python3 src/upload_docusign_template.py --file <path> --force-create
|
||||||
|
|
||||||
First-time setup:
|
First-time setup:
|
||||||
python3 src/docusign_auth.py --consent # grant consent once
|
python3 src/docusign_auth.py --authorize # authorize once
|
||||||
python3 src/upload_docusign_template.py --file <path>
|
python3 src/upload_docusign_template.py --file <path>
|
||||||
|
|
||||||
Required .env keys (see docusign_auth.py for full list):
|
Required .env keys (see docusign_auth.py for full list):
|
||||||
DOCUSIGN_CLIENT_ID, DOCUSIGN_USER_ID, DOCUSIGN_ACCOUNT_ID,
|
DOCUSIGN_CLIENT_ID, DOCUSIGN_CLIENT_SECRET, DOCUSIGN_ACCOUNT_ID,
|
||||||
DOCUSIGN_PRIVATE_KEY_PATH, DOCUSIGN_AUTH_SERVER, DOCUSIGN_BASE_URL
|
DOCUSIGN_AUTH_SERVER, DOCUSIGN_BASE_URL, DOCUSIGN_REDIRECT_URI
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ Then open [http://localhost:8000](http://localhost:8000).
|
||||||
|
|
||||||
- [ ] Top bar shows two disconnected chips (red dot): "Adobe Sign" and "DocuSign"
|
- [ ] Top bar shows two disconnected chips (red dot): "Adobe Sign" and "DocuSign"
|
||||||
- [ ] Click "Adobe Sign" chip → connects via `.env` refresh token → chip turns green
|
- [ ] Click "Adobe Sign" chip → connects via `.env` refresh token → chip turns green
|
||||||
- [ ] Click "DocuSign" chip → connects via JWT grant → chip turns green
|
- [ ] Click "DocuSign" chip → redirects through OAuth if needed, then chip turns green
|
||||||
- [ ] Disconnecting either chip → chip turns red → templates clear
|
- [ ] Disconnecting either chip → chip turns red → templates clear
|
||||||
|
|
||||||
## 4. Templates View
|
## 4. Templates View
|
||||||
|
|
|
||||||
|
|
@ -96,11 +96,10 @@ def test_adobe_exchange_rejects_missing_code():
|
||||||
|
|
||||||
|
|
||||||
def test_docusign_connect_stores_token():
|
def test_docusign_connect_stores_token():
|
||||||
"""GET /api/auth/docusign/connect uses JWT grant from .env → session connected."""
|
"""GET /api/auth/docusign/connect uses cached OAuth credentials → session connected."""
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
import web.routers.auth as auth_module
|
|
||||||
|
|
||||||
with patch("docusign_auth.get_access_token", return_value="ds-jwt-token"):
|
with patch("docusign_auth.get_access_token", return_value="ds-oauth-token"):
|
||||||
resp = client.get("/api/auth/docusign/connect")
|
resp = client.get("/api/auth/docusign/connect")
|
||||||
|
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
|
|
@ -113,6 +112,19 @@ def test_docusign_connect_stores_token():
|
||||||
assert status_resp.json()["docusign"] is True
|
assert status_resp.json()["docusign"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_docusign_connect_requests_authorization_when_refresh_token_missing():
|
||||||
|
"""GET /api/auth/docusign/connect returns an auth URL when first-time authorization is needed."""
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
with patch("docusign_auth.get_access_token", side_effect=RuntimeError("No DocuSign refresh token found")), \
|
||||||
|
patch("docusign_auth.build_authorization_url", return_value="https://example.com/oauth"):
|
||||||
|
resp = client.get("/api/auth/docusign/connect")
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["authorization_required"] is True
|
||||||
|
assert resp.json()["authorization_url"] == "https://example.com/oauth"
|
||||||
|
|
||||||
|
|
||||||
@respx.mock
|
@respx.mock
|
||||||
def test_disconnect_clears_token():
|
def test_disconnect_clears_token():
|
||||||
"""After disconnect, status shows platform as disconnected."""
|
"""After disconnect, status shows platform as disconnected."""
|
||||||
|
|
|
||||||
|
|
@ -164,24 +164,33 @@ def adobe_disconnect(request: Request):
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# DocuSign — JWT grant (.env) or OAuth redirect
|
# DocuSign — Auth Code Grant + refresh token
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@router.get("/docusign/connect")
|
@router.get("/docusign/connect")
|
||||||
def docusign_connect(request: Request):
|
def docusign_connect(request: Request):
|
||||||
"""
|
"""
|
||||||
Obtain a DocuSign access token via JWT grant using the credentials already
|
Obtain a DocuSign access token from cached OAuth credentials in .env.
|
||||||
in .env (DOCUSIGN_CLIENT_ID, DOCUSIGN_USER_ID, DOCUSIGN_PRIVATE_KEY_PATH).
|
If the app has not been authorized yet, return the authorization URL so the
|
||||||
No browser sign-in needed — consent was already granted via the CLI setup.
|
frontend can start the browser flow.
|
||||||
"""
|
"""
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src"))
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src"))
|
||||||
from docusign_auth import get_access_token
|
from docusign_auth import build_authorization_url, get_access_token
|
||||||
|
|
||||||
try:
|
try:
|
||||||
token = get_access_token()
|
token = get_access_token()
|
||||||
except RuntimeError as e:
|
except RuntimeError as e:
|
||||||
|
if "refresh token" in str(e).lower():
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"connected": False,
|
||||||
|
"authorization_required": True,
|
||||||
|
"authorization_url": build_authorization_url(),
|
||||||
|
},
|
||||||
|
status_code=200,
|
||||||
|
)
|
||||||
return JSONResponse({"error": str(e)}, status_code=500)
|
return JSONResponse({"error": str(e)}, status_code=500)
|
||||||
|
|
||||||
session = get_session(request)
|
session = get_session(request)
|
||||||
|
|
@ -194,46 +203,32 @@ def docusign_connect(request: Request):
|
||||||
|
|
||||||
@router.get("/docusign/start")
|
@router.get("/docusign/start")
|
||||||
def docusign_start():
|
def docusign_start():
|
||||||
"""Redirect to DocuSign OAuth (alternative to JWT grant)."""
|
"""Redirect to the DocuSign OAuth authorization screen."""
|
||||||
import base64 as _b64
|
import sys
|
||||||
params = (
|
import os
|
||||||
f"?response_type=code"
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src"))
|
||||||
f"&scope=signature"
|
from docusign_auth import build_authorization_url
|
||||||
f"&client_id={settings.docusign_client_id}"
|
|
||||||
f"&redirect_uri={settings.docusign_redirect_uri}"
|
return RedirectResponse(build_authorization_url())
|
||||||
)
|
|
||||||
return RedirectResponse(f"https://{settings.docusign_auth_server}/oauth/auth" + params)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/docusign/callback")
|
@router.get("/docusign/callback")
|
||||||
async def docusign_callback(request: Request, code: str = ""):
|
async def docusign_callback(request: Request, code: str = ""):
|
||||||
"""Handle DocuSign OAuth redirect callback."""
|
"""Handle DocuSign OAuth redirect callback."""
|
||||||
import base64
|
import sys
|
||||||
|
import os
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src"))
|
||||||
|
from docusign_auth import save_code_token_exchange
|
||||||
|
|
||||||
if not code:
|
if not code:
|
||||||
return JSONResponse({"error": "missing code"}, status_code=400)
|
return JSONResponse({"error": "missing code"}, status_code=400)
|
||||||
|
|
||||||
credentials = base64.b64encode(
|
|
||||||
f"{settings.docusign_client_id}:{settings.docusign_client_secret}".encode()
|
|
||||||
).decode()
|
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
resp = await client.post(
|
|
||||||
f"https://{settings.docusign_auth_server}/oauth/token",
|
|
||||||
headers={"Authorization": f"Basic {credentials}"},
|
|
||||||
data={
|
|
||||||
"grant_type": "authorization_code",
|
|
||||||
"code": code,
|
|
||||||
"redirect_uri": settings.docusign_redirect_uri,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
if not resp.is_success:
|
|
||||||
return JSONResponse({"error": "token exchange failed", "detail": resp.text}, status_code=502)
|
|
||||||
|
|
||||||
token_data = resp.json()
|
|
||||||
session = get_session(request)
|
session = get_session(request)
|
||||||
session["docusign_access_token"] = token_data.get("access_token")
|
try:
|
||||||
session["docusign_refresh_token"] = token_data.get("refresh_token")
|
session["docusign_access_token"] = save_code_token_exchange(code)
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse({"error": "token exchange failed", "detail": str(e)}, status_code=502)
|
||||||
|
|
||||||
|
session["docusign_refresh_token"] = os.getenv("DOCUSIGN_REFRESH_TOKEN")
|
||||||
|
|
||||||
response = RedirectResponse("/")
|
response = RedirectResponse("/")
|
||||||
save_session(response, session)
|
save_session(response, session)
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,8 @@ async function connectDocusign() {
|
||||||
renderAuthChips();
|
renderAuthChips();
|
||||||
const { refreshTemplates } = await import('./templates.js');
|
const { refreshTemplates } = await import('./templates.js');
|
||||||
refreshTemplates();
|
refreshTemplates();
|
||||||
|
} else if (data.authorization_required && data.authorization_url) {
|
||||||
|
window.location.href = data.authorization_url;
|
||||||
} else {
|
} else {
|
||||||
renderAuthChips();
|
renderAuthChips();
|
||||||
showToast('Docusign error: ' + (data.error || 'unknown'), 'error');
|
showToast('Docusign error: ' + (data.error || 'unknown'), 'error');
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue