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
|
||||
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
|
||||
|
||||
# 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
|
||||
# Found in the DocuSign admin UI under Settings → Account Profile
|
||||
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
|
||||
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)
|
||||
DOCUSIGN_BASE_URL=https://demo.docusign.net/restapi
|
||||
|
||||
# Redirect URI registered in your DocuSign app (used only during one-time consent flow)
|
||||
DOCUSIGN_REDIRECT_URI=http://localhost:8080/callback
|
||||
# Redirect URI registered in your DocuSign app
|
||||
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.
|
||||
DOCUSIGN_ACCESS_TOKEN=
|
||||
DOCUSIGN_REFRESH_TOKEN=
|
||||
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`)
|
||||
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
|
||||
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
|
||||
|
||||
---
|
||||
|
|
@ -19,7 +19,7 @@ It downloads templates via the Adobe Sign API, converts them to DocuSign format,
|
|||
|
||||
- Python 3.10+
|
||||
- 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.
|
||||
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
|
||||
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
|
||||
redirect URL back into the terminal. This grants the `impersonation` scope required
|
||||
for JWT grant. After this runs once, all subsequent API calls use JWT automatically —
|
||||
no further browser interaction needed.
|
||||
Opens a browser for the DocuSign OAuth screen. After approving, paste the
|
||||
redirect URL back into the terminal. The app stores both access and refresh
|
||||
tokens in `.env`, and later API calls refresh access tokens automatically.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -274,7 +273,7 @@ src/
|
|||
adobe_api.py # Adobe Sign API client (auto token refresh)
|
||||
download_templates.py # List and download templates from Adobe Sign
|
||||
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
|
||||
migrate_template.py # End-to-end CLI runner (download → convert → upload)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,31 +1,23 @@
|
|||
"""
|
||||
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.
|
||||
Handles DocuSign OAuth using the Authorization Code Grant.
|
||||
|
||||
Usage:
|
||||
python3 src/docusign_auth.py --consent # one-time browser consent
|
||||
python3 src/docusign_auth.py # print a fresh access token (smoke test)
|
||||
python3 src/docusign_auth.py --authorize # one-time browser login
|
||||
python3 src/docusign_auth.py # print a fresh access token
|
||||
|
||||
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)
|
||||
DOCUSIGN_CLIENT_ID
|
||||
DOCUSIGN_CLIENT_SECRET
|
||||
DOCUSIGN_AUTH_SERVER
|
||||
DOCUSIGN_REDIRECT_URI
|
||||
DOCUSIGN_BASE_URL
|
||||
|
||||
For --consent only:
|
||||
DOCUSIGN_CLIENT_SECRET OAuth client secret
|
||||
DOCUSIGN_REDIRECT_URI Must match your app config (default: http://localhost:8080/callback)
|
||||
Auto-written to .env after authorization:
|
||||
DOCUSIGN_ACCESS_TOKEN
|
||||
DOCUSIGN_REFRESH_TOKEN
|
||||
DOCUSIGN_TOKEN_EXPIRY
|
||||
"""
|
||||
|
||||
import argparse
|
||||
|
|
@ -33,89 +25,36 @@ import os
|
|||
import sys
|
||||
import time
|
||||
import webbrowser
|
||||
from urllib.parse import urlencode, urlparse, parse_qs
|
||||
from urllib.parse import parse_qs, urlencode, urlparse
|
||||
|
||||
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
|
||||
TOKEN_EXPIRY_BUFFER = 120
|
||||
DOCUSIGN_SCOPE = "signature"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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 _required_env(name: str) -> str:
|
||||
value = os.getenv(name)
|
||||
if not value:
|
||||
raise RuntimeError(f"{name} must be set in .env")
|
||||
return value
|
||||
|
||||
|
||||
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 _auth_server() -> str:
|
||||
return os.getenv("DOCUSIGN_AUTH_SERVER", "account-d.docusign.com")
|
||||
|
||||
|
||||
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")
|
||||
def _redirect_uri() -> str:
|
||||
return os.getenv("DOCUSIGN_REDIRECT_URI", "http://localhost:8000/api/auth/docusign/callback")
|
||||
|
||||
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"]
|
||||
refresh_token = token_data.get("refresh_token")
|
||||
expiry = int(time.time()) + int(token_data.get("expires_in", 3600))
|
||||
|
||||
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_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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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")
|
||||
|
||||
def build_authorization_url() -> str:
|
||||
client_id = _required_env("DOCUSIGN_CLIENT_ID")
|
||||
params = {
|
||||
"response_type": "code",
|
||||
"scope": "signature impersonation",
|
||||
"scope": DOCUSIGN_SCOPE,
|
||||
"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):
|
||||
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")
|
||||
def exchange_code_for_token(code: str) -> dict:
|
||||
client_id = _required_env("DOCUSIGN_CLIENT_ID")
|
||||
client_secret = _required_env("DOCUSIGN_CLIENT_SECRET")
|
||||
|
||||
resp = requests.post(
|
||||
f"https://{auth_server}/oauth/token",
|
||||
f"https://{_auth_server()}/oauth/token",
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": redirect_uri,
|
||||
"redirect_uri": _redirect_uri(),
|
||||
},
|
||||
auth=(client_id, client_secret),
|
||||
)
|
||||
|
|
@ -167,18 +98,53 @@ def _exchange_code(code: str):
|
|||
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...")
|
||||
def refresh_access_token(refresh_token: str | None = None) -> dict:
|
||||
client_id = _required_env("DOCUSIGN_CLIENT_ID")
|
||||
client_secret = _required_env("DOCUSIGN_CLIENT_SECRET")
|
||||
refresh_token = refresh_token or os.getenv("DOCUSIGN_REFRESH_TOKEN")
|
||||
|
||||
if not refresh_token:
|
||||
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")
|
||||
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")
|
||||
print("Log in, approve access, then paste the full redirect URL here.\n")
|
||||
redirected_url = input("Paste the redirect URL: ").strip()
|
||||
|
||||
parsed = urlparse(redirected_url)
|
||||
|
|
@ -189,37 +155,32 @@ def run_consent_flow():
|
|||
print(f"ERROR: {error}")
|
||||
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.")
|
||||
sys.exit(1)
|
||||
|
||||
code = params["code"][0]
|
||||
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():
|
||||
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)")
|
||||
parser.add_argument(
|
||||
"--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()
|
||||
|
||||
if args.consent:
|
||||
run_consent_flow()
|
||||
if args.authorize or args.consent:
|
||||
run_authorize_flow()
|
||||
else:
|
||||
token = get_access_token()
|
||||
print(f"Access token: {token[:20]}... (valid for ~1 hour)")
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
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).
|
||||
Authenticates using DocuSign OAuth tokens stored in .env.
|
||||
|
||||
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
|
||||
|
|
@ -13,12 +13,12 @@ Usage:
|
|||
python3 src/upload_docusign_template.py --file <path> --force-create
|
||||
|
||||
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>
|
||||
|
||||
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
|
||||
DOCUSIGN_CLIENT_ID, DOCUSIGN_CLIENT_SECRET, DOCUSIGN_ACCOUNT_ID,
|
||||
DOCUSIGN_AUTH_SERVER, DOCUSIGN_BASE_URL, DOCUSIGN_REDIRECT_URI
|
||||
"""
|
||||
|
||||
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"
|
||||
- [ ] 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
|
||||
|
||||
## 4. Templates View
|
||||
|
|
|
|||
|
|
@ -96,11 +96,10 @@ def test_adobe_exchange_rejects_missing_code():
|
|||
|
||||
|
||||
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
|
||||
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")
|
||||
|
||||
assert resp.status_code == 200
|
||||
|
|
@ -113,6 +112,19 @@ def test_docusign_connect_stores_token():
|
|||
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
|
||||
def test_disconnect_clears_token():
|
||||
"""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")
|
||||
def docusign_connect(request: Request):
|
||||
"""
|
||||
Obtain a DocuSign access token via JWT grant using the credentials already
|
||||
in .env (DOCUSIGN_CLIENT_ID, DOCUSIGN_USER_ID, DOCUSIGN_PRIVATE_KEY_PATH).
|
||||
No browser sign-in needed — consent was already granted via the CLI setup.
|
||||
Obtain a DocuSign access token from cached OAuth credentials in .env.
|
||||
If the app has not been authorized yet, return the authorization URL so the
|
||||
frontend can start the browser flow.
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
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:
|
||||
token = get_access_token()
|
||||
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)
|
||||
|
||||
session = get_session(request)
|
||||
|
|
@ -194,46 +203,32 @@ def docusign_connect(request: Request):
|
|||
|
||||
@router.get("/docusign/start")
|
||||
def docusign_start():
|
||||
"""Redirect to DocuSign OAuth (alternative to JWT grant)."""
|
||||
import base64 as _b64
|
||||
params = (
|
||||
f"?response_type=code"
|
||||
f"&scope=signature"
|
||||
f"&client_id={settings.docusign_client_id}"
|
||||
f"&redirect_uri={settings.docusign_redirect_uri}"
|
||||
)
|
||||
return RedirectResponse(f"https://{settings.docusign_auth_server}/oauth/auth" + params)
|
||||
"""Redirect to the DocuSign OAuth authorization screen."""
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src"))
|
||||
from docusign_auth import build_authorization_url
|
||||
|
||||
return RedirectResponse(build_authorization_url())
|
||||
|
||||
|
||||
@router.get("/docusign/callback")
|
||||
async def docusign_callback(request: Request, code: str = ""):
|
||||
"""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:
|
||||
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["docusign_access_token"] = token_data.get("access_token")
|
||||
session["docusign_refresh_token"] = token_data.get("refresh_token")
|
||||
try:
|
||||
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("/")
|
||||
save_session(response, session)
|
||||
|
|
|
|||
|
|
@ -100,6 +100,8 @@ async function connectDocusign() {
|
|||
renderAuthChips();
|
||||
const { refreshTemplates } = await import('./templates.js');
|
||||
refreshTemplates();
|
||||
} else if (data.authorization_required && data.authorization_url) {
|
||||
window.location.href = data.authorization_url;
|
||||
} else {
|
||||
renderAuthChips();
|
||||
showToast('Docusign error: ' + (data.error || 'unknown'), 'error');
|
||||
|
|
|
|||
Loading…
Reference in New Issue