From 3be39039866055d93486d839866cbd7cbec3f164 Mon Sep 17 00:00:00 2001 From: Paul Huliganga Date: Tue, 21 Apr 2026 16:25:56 -0400 Subject: [PATCH 1/2] Switch DocuSign auth to authorization code flow --- .env-sample | 17 +-- src/docusign_auth.py | 239 +++++++++++++------------------- src/upload_docusign_template.py | 8 +- web/routers/auth.py | 69 +++++---- web/static/js/auth.js | 2 + 5 files changed, 143 insertions(+), 192 deletions(-) diff --git a/.env-sample b/.env-sample index ee263aa..a201f82 100644 --- a/.env-sample +++ b/.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= diff --git a/src/docusign_auth.py b/src/docusign_auth.py index a287fbe..fd24777 100644 --- a/src/docusign_auth.py +++ b/src/docusign_auth.py @@ -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)") diff --git a/src/upload_docusign_template.py b/src/upload_docusign_template.py index 10addad..5fe0706 100644 --- a/src/upload_docusign_template.py +++ b/src/upload_docusign_template.py @@ -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 --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 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 diff --git a/web/routers/auth.py b/web/routers/auth.py index 628b31f..5a1d9d3 100644 --- a/web/routers/auth.py +++ b/web/routers/auth.py @@ -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) diff --git a/web/static/js/auth.js b/web/static/js/auth.js index cb4263d..5943871 100644 --- a/web/static/js/auth.js +++ b/web/static/js/auth.js @@ -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'); From 3b27a0fd5b4f7d33865e44ef930de43b8f1e4d76 Mon Sep 17 00:00:00 2001 From: Paul Huliganga Date: Tue, 21 Apr 2026 16:26:13 -0400 Subject: [PATCH 2/2] Update DocuSign auth docs and coverage --- README.md | 17 ++++++++--------- tests/UI-SMOKE-TEST.md | 2 +- tests/test_api_auth.py | 18 +++++++++++++++--- 3 files changed, 24 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index e521a55..7ce58aa 100644 --- a/README.md +++ b/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) diff --git a/tests/UI-SMOKE-TEST.md b/tests/UI-SMOKE-TEST.md index 728c7c9..de519bf 100644 --- a/tests/UI-SMOKE-TEST.md +++ b/tests/UI-SMOKE-TEST.md @@ -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 diff --git a/tests/test_api_auth.py b/tests/test_api_auth.py index b001fc0..d610a4f 100644 --- a/tests/test_api_auth.py +++ b/tests/test_api_auth.py @@ -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."""