diff --git a/.env-sample b/.env-sample index a201f82..50404ea 100644 --- a/.env-sample +++ b/.env-sample @@ -42,3 +42,12 @@ DOCUSIGN_REDIRECT_URI=http://localhost:8000/api/auth/docusign/callback DOCUSIGN_ACCESS_TOKEN= DOCUSIGN_REFRESH_TOKEN= DOCUSIGN_TOKEN_EXPIRY= + +# ─── Web UI sessions ──────────────────────────────────────────────────────── + +# Required for browser session signing. +SESSION_SECRET_KEY=change-me + +# Optional override for the server-side browser session store. +# Each tester gets an isolated session file here after they connect in the web UI. +SESSION_STORE_DIR= diff --git a/.gitignore b/.gitignore index 25b5b77..c8a1993 100644 --- a/.gitignore +++ b/.gitignore @@ -9,5 +9,6 @@ __pycache__/ *.b64 downloads/ migration-output/ +.session-store/ *.pdf private.key diff --git a/README.md b/README.md index 7ce58aa..df49f7d 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ 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. Authorize DocuSign** (one-time per user): +**4. Authorize DocuSign** (CLI, one-time per machine/user): ```bash python3 src/docusign_auth.py --authorize ``` @@ -107,6 +107,7 @@ shell, multi-customer project context, and a full migration workflow. **Additional `.env` keys required for the web UI:** ``` SESSION_SECRET_KEY= +SESSION_STORE_DIR=/absolute/path/for/browser-session-files DOCUSIGN_CLIENT_SECRET= DOCUSIGN_REDIRECT_URI=http://localhost:8000/api/auth/docusign/callback ADOBE_REDIRECT_URI=http://localhost:8000/api/auth/adobe/callback @@ -118,6 +119,22 @@ uvicorn web.app:app --reload --port 8000 ``` Then open [http://localhost:8000](http://localhost:8000) in your browser. +### Multi-user testing + +The web UI now supports concurrent testers on one shared deployment: + +- each browser gets its own server-side session file +- DocuSign web OAuth is isolated per tester session +- migration history and batch-job polling are scoped to that tester session +- Adobe Sign can still be connected from shared `.env` credentials if you use the top-bar Adobe connect flow + +Important behavior: + +- the CLI still stores DocuSign tokens in `.env` +- the web UI does **not** reuse `.env` DocuSign refresh tokens for all users anymore +- each tester who needs DocuSign upload/verification should connect DocuSign in their own browser session +- browser-session files live under `.session-store/` by default and can be deleted to force reconnects + ### Navigation | Screen | Path | Purpose | @@ -133,6 +150,8 @@ Then open [http://localhost:8000](http://localhost:8000) in your browser. 1. **Create a project** — the switcher modal opens on first run; name it after the customer. 2. **Connect platforms** — click the Adobe Sign and Docusign chips in the top bar. + - For group testing, each tester should connect Docusign in their own browser. + - Settings now shows the browser session ID and auth mode for easier troubleshooting. 3. **Review templates** — the Templates view shows readiness badges: - **Ready** (green) — no issues, safe to migrate - **Caveats** (amber) — warnings exist; migration will proceed but check Issues view diff --git a/docs/oracle-vm-deploy-cheat-sheet.md b/docs/oracle-vm-deploy-cheat-sheet.md index 118a791..065199b 100644 --- a/docs/oracle-vm-deploy-cheat-sheet.md +++ b/docs/oracle-vm-deploy-cheat-sheet.md @@ -1,6 +1,6 @@ # Oracle VM Deploy Cheat Sheet — Adobe Sign → DocuSign Migrator -_Last updated: 2026-04-21_ +_Last updated: 2026-04-21 (post-deploy note added)_ This is the short version of the deployment process. Use this when you already know what you are doing and just need the commands. @@ -67,6 +67,13 @@ Using hostname: ssh ubuntu@dstemplate.mooo.com ``` +If SSH fails on a new machine with host key verification issues: + +```bash +ssh-keyscan -H dstemplate.mooo.com >> ~/.ssh/known_hosts +ssh ubuntu@dstemplate.mooo.com +``` + --- ## 5. Pull latest code on VM @@ -204,4 +211,5 @@ ssh ubuntu@dstemplate.mooo.com ' - Run tests first - Commit before deploy - Don’t overwrite `.env`, `.env-adobe`, or `private.key` casually +- Don’t casually delete `.session-store/` while testers are active - If the site breaks, check `journalctl -u adobe-migrator` diff --git a/docs/oracle-vm-deployment.md b/docs/oracle-vm-deployment.md index 363ae85..3bb185e 100644 --- a/docs/oracle-vm-deployment.md +++ b/docs/oracle-vm-deployment.md @@ -1,6 +1,6 @@ # Deploying the Adobe Sign → DocuSign Migrator to the Oracle Cloud VM -_Last updated: 2026-04-21_ +_Last updated: 2026-04-21 (post-deploy note added)_ This document explains: 1. the **current live deployment setup** on the Oracle VM @@ -123,6 +123,7 @@ Important files on the VM: /home/ubuntu/projects/adobe-to-docusign-migrator/.env /home/ubuntu/projects/adobe-to-docusign-migrator/.env-adobe /home/ubuntu/projects/adobe-to-docusign-migrator/private.key +/home/ubuntu/projects/adobe-to-docusign-migrator/.session-store/ ``` These should **not** be overwritten casually. @@ -131,6 +132,7 @@ They likely contain: - DocuSign credentials - refresh tokens / secrets - app configuration +- active browser-session files for concurrent testers Before a risky deploy, back them up. @@ -159,13 +161,26 @@ The deployed copy currently exists at: ``` ### Important note -At the time this doc was written: -- local workspace branch: `ui-redesign` -- Oracle VM branch: `master` +Current expected deployment flow: +- local workspace deployment branch: `master` +- Oracle VM deployment branch: `master` So deployment should be done intentionally. Do not assume the VM is following your current local branch automatically. +### SSH note for new machines +If you SSH to `dstemplate.mooo.com` from a machine that has never connected before, you may need to accept or record the server host key first. + +Example: +```bash +ssh-keyscan -H dstemplate.mooo.com >> ~/.ssh/known_hosts +``` + +Then connect normally: +```bash +ssh ubuntu@dstemplate.mooo.com +``` + --- ## 6. Standard Deployment Procedure @@ -458,8 +473,9 @@ Use this carefully. Git-based deployment is cleaner. 3. **Commit before deploy.** 4. **Prefer Git pull on the VM over manual file copying.** 5. **Do not overwrite `.env`, `.env-adobe`, or `private.key` unless intended.** -6. **Restart the systemd service after code changes.** -7. **Smoke test both localhost and the public URL.** +6. **Do not casually delete `.session-store/` during active testing.** +7. **Restart the systemd service after code changes.** +8. **Smoke test both localhost and the public URL.** --- diff --git a/src/docusign_auth.py b/src/docusign_auth.py index fd24777..b0e9551 100644 --- a/src/docusign_auth.py +++ b/src/docusign_auth.py @@ -70,7 +70,7 @@ def _persist_token_data(token_data: dict) -> str: return access_token -def build_authorization_url() -> str: +def build_authorization_url(state: str | None = None) -> str: client_id = _required_env("DOCUSIGN_CLIENT_ID") params = { "response_type": "code", @@ -78,6 +78,8 @@ def build_authorization_url() -> str: "client_id": client_id, "redirect_uri": _redirect_uri(), } + if state: + params["state"] = state return f"https://{_auth_server()}/oauth/auth?{urlencode(params)}" @@ -125,6 +127,30 @@ def save_code_token_exchange(code: str) -> str: return _persist_token_data(token_data) +def session_from_token_data(token_data: dict, current_session: dict | None = None) -> dict: + """ + Merge DocuSign OAuth token data into a web-session dict without writing .env. + """ + session = dict(current_session or {}) + session["docusign_access_token"] = token_data["access_token"] + session["docusign_token_expiry"] = int(time.time()) + int(token_data.get("expires_in", 3600)) + session["docusign_auth_mode"] = "session_oauth" + if token_data.get("refresh_token"): + session["docusign_refresh_token"] = token_data["refresh_token"] + return session + + +def session_has_valid_access_token(session: dict) -> bool: + token = session.get("docusign_access_token") + expiry = session.get("docusign_token_expiry") + if not token or not expiry: + return False + try: + return int(time.time()) < int(expiry) - TOKEN_EXPIRY_BUFFER + except (TypeError, ValueError): + return False + + def get_access_token() -> str: """Return a valid DocuSign access token using cached or refreshed OAuth tokens.""" cached_token = os.getenv("DOCUSIGN_ACCESS_TOKEN") diff --git a/tests/test_api_auth.py b/tests/test_api_auth.py index d610a4f..29feacb 100644 --- a/tests/test_api_auth.py +++ b/tests/test_api_auth.py @@ -12,10 +12,20 @@ from fastapi.testclient import TestClient from web.app import app from web.routers.auth import _ADOBE_TOKEN_URL +from web.session import create_test_session client = TestClient(app, raise_server_exceptions=True) +@pytest.fixture(autouse=True) +def temp_session_store(tmp_path, monkeypatch): + import web.config as cfg + monkeypatch.setattr(cfg.settings, "session_store_dir", str(tmp_path / ".session-store")) + client.cookies.clear() + yield + client.cookies.clear() + + def test_status_unauthenticated(): """Fresh session → both platforms disconnected.""" resp = client.get("/api/auth/status", cookies={}) @@ -96,11 +106,12 @@ def test_adobe_exchange_rejects_missing_code(): def test_docusign_connect_stores_token(): - """GET /api/auth/docusign/connect uses cached OAuth credentials → session connected.""" + """GET /api/auth/docusign/connect refreshes the current session's token.""" from unittest.mock import patch - with patch("docusign_auth.get_access_token", return_value="ds-oauth-token"): - resp = client.get("/api/auth/docusign/connect") + cookie = create_test_session({"docusign_refresh_token": "refresh-123"}) + with patch("docusign_auth.refresh_access_token", return_value={"access_token": "ds-oauth-token", "refresh_token": "refresh-456", "expires_in": 3600}): + resp = client.get("/api/auth/docusign/connect", cookies={"migrator_session": cookie}) assert resp.status_code == 200 assert resp.json()["connected"] is True @@ -113,16 +124,76 @@ def test_docusign_connect_stores_token(): 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.""" + """GET /api/auth/docusign/connect returns a session-scoped auth URL when auth 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"): + with 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" + assert resp.cookies.get("migrator_session") is not None + + +def test_docusign_callback_requires_matching_state(): + """DocuSign callback is rejected when the session state token does not match.""" + cookie = create_test_session({"docusign_oauth_state": "expected-state"}) + resp = client.get( + "/api/auth/docusign/callback?code=authcode123&state=wrong-state", + cookies={"migrator_session": cookie}, + ) + assert resp.status_code == 400 + assert "invalid oauth state" in resp.json()["error"] + + +def test_docusign_callback_stores_per_session_tokens(): + """DocuSign callback stores refresh/access tokens in this browser session only.""" + from unittest.mock import patch + + cookie = create_test_session({"docusign_oauth_state": "expected-state"}) + with patch( + "docusign_auth.exchange_code_for_token", + return_value={"access_token": "access-123", "refresh_token": "refresh-123", "expires_in": 3600}, + ): + resp = client.get( + "/api/auth/docusign/callback?code=authcode123&state=expected-state", + cookies={"migrator_session": cookie}, + follow_redirects=False, + ) + + assert resp.status_code in (302, 307) + session_cookie = resp.cookies.get("migrator_session") + status_resp = client.get("/api/auth/status", cookies={"migrator_session": session_cookie}) + assert status_resp.json()["docusign"] is True + assert status_resp.json()["docusign_auth_mode"] == "session_oauth" + + +def test_docusign_sessions_are_isolated(): + """One tester's DocuSign connection does not authenticate another tester.""" + from unittest.mock import patch + + session_a = create_test_session({"docusign_oauth_state": "state-a"}) + session_b = create_test_session({}) + + with patch( + "docusign_auth.exchange_code_for_token", + return_value={"access_token": "access-a", "refresh_token": "refresh-a", "expires_in": 3600}, + ): + with TestClient(app, raise_server_exceptions=True) as client_a: + callback_resp = client_a.get( + "/api/auth/docusign/callback?code=authcode123&state=state-a", + cookies={"migrator_session": session_a}, + follow_redirects=False, + ) + cookie_a = callback_resp.cookies.get("migrator_session") + status_a = client_a.get("/api/auth/status", cookies={"migrator_session": cookie_a}) + + with TestClient(app, raise_server_exceptions=True) as client_b: + status_b = client_b.get("/api/auth/status", cookies={"migrator_session": session_b}) + + assert status_a.json()["docusign"] is True + assert status_b.json()["docusign"] is False @respx.mock diff --git a/web/config.py b/web/config.py index db86b74..5c7d56a 100644 --- a/web/config.py +++ b/web/config.py @@ -28,6 +28,10 @@ class Settings: # Session session_secret_key: str = os.getenv("SESSION_SECRET_KEY", "dev-secret-change-in-production") + session_store_dir: str = os.getenv( + "SESSION_STORE_DIR", + os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".session-store")), + ) # App version: str = "2.0" diff --git a/web/routers/auth.py b/web/routers/auth.py index 5a1d9d3..ba1475c 100644 --- a/web/routers/auth.py +++ b/web/routers/auth.py @@ -9,9 +9,10 @@ the URL and submits it via POST /api/auth/adobe/exchange — identical to the CL DocuSign uses a standard redirect callback handled directly by this server. -Tokens are stored in a signed session cookie. +Tokens are stored in a server-side session keyed by a signed browser cookie. """ +import secrets from urllib.parse import urlparse, parse_qs import httpx @@ -20,7 +21,7 @@ from fastapi.responses import JSONResponse, RedirectResponse from pydantic import BaseModel from web.config import settings -from web.session import get_session, save_session +from web.session import get_session, get_session_id, save_session, session_public_view router = APIRouter() @@ -39,8 +40,11 @@ def auth_status(request: Request): """Returns which platforms the current session is connected to.""" session = get_session(request) return { - "adobe": bool(session.get("adobe_access_token")), - "docusign": bool(session.get("docusign_access_token")), + **session_public_view(session), + "adobe_label": "Adobe Sign", + "docusign_label": session.get("docusign_user_name") or "Docusign", + "docusign_account_id": settings.docusign_account_id, + "base_url": settings.docusign_base_url, } @@ -110,6 +114,7 @@ async def adobe_exchange(body: AdobeExchangeRequest, request: Request): session = get_session(request) session["adobe_access_token"] = token_data.get("access_token") session["adobe_refresh_token"] = token_data.get("refresh_token") + session["adobe_auth_mode"] = "session_oauth" response = JSONResponse({"connected": True}) save_session(response, session) @@ -147,6 +152,7 @@ def adobe_connect_env(request: Request): session = get_session(request) session["adobe_access_token"] = token session["adobe_refresh_token"] = refresh_token + session["adobe_auth_mode"] = "shared_env" response = JSONResponse({"connected": True}) save_session(response, session) @@ -158,6 +164,7 @@ def adobe_disconnect(request: Request): session = get_session(request) session.pop("adobe_access_token", None) session.pop("adobe_refresh_token", None) + session["adobe_auth_mode"] = "disconnected" response = JSONResponse({"disconnected": "adobe"}) save_session(response, session) return response @@ -170,31 +177,49 @@ def adobe_disconnect(request: Request): @router.get("/docusign/connect") def docusign_connect(request: Request): """ - 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. + Obtain a DocuSign access token from the current browser session. + If the session has not been authorized yet, return an authorization URL so + the frontend can start an isolated OAuth flow for this tester. """ import sys import os sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src")) - from docusign_auth import build_authorization_url, get_access_token + from docusign_auth import ( + build_authorization_url, + refresh_access_token, + session_from_token_data, + session_has_valid_access_token, + ) + session = get_session(request) try: - token = get_access_token() + if session_has_valid_access_token(session): + token = session["docusign_access_token"] + elif session.get("docusign_refresh_token"): + token_data = refresh_access_token(session["docusign_refresh_token"]) + session = session_from_token_data(token_data, session) + token = session["docusign_access_token"] + else: + raise RuntimeError("No DocuSign refresh token found for this browser session.") except RuntimeError as e: if "refresh token" in str(e).lower(): - return JSONResponse( + state = secrets.token_urlsafe(24) + session["docusign_oauth_state"] = state + session["docusign_auth_mode"] = "authorization_pending" + response = JSONResponse( { "connected": False, "authorization_required": True, - "authorization_url": build_authorization_url(), + "authorization_url": build_authorization_url(state=state), }, status_code=200, ) + save_session(response, session) + return response return JSONResponse({"error": str(e)}, status_code=500) - session = get_session(request) session["docusign_access_token"] = token + session["docusign_auth_mode"] = "session_oauth" response = JSONResponse({"connected": True}) save_session(response, session) @@ -202,33 +227,46 @@ def docusign_connect(request: Request): @router.get("/docusign/start") -def docusign_start(): - """Redirect to the DocuSign OAuth authorization screen.""" +def docusign_start(request: Request): + """Redirect to the DocuSign OAuth authorization screen for this browser session.""" 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()) + session = get_session(request) + state = secrets.token_urlsafe(24) + session["docusign_oauth_state"] = state + session["docusign_auth_mode"] = "authorization_pending" + response = RedirectResponse(build_authorization_url(state=state)) + save_session(response, session) + return response @router.get("/docusign/callback") -async def docusign_callback(request: Request, code: str = ""): +async def docusign_callback(request: Request, code: str = "", state: str = ""): """Handle DocuSign OAuth redirect callback.""" import sys import os sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src")) - from docusign_auth import save_code_token_exchange + from docusign_auth import exchange_code_for_token, session_from_token_data if not code: return JSONResponse({"error": "missing code"}, status_code=400) + session = get_session(request) + expected_state = session.get("docusign_oauth_state") + if not expected_state or state != expected_state: + return JSONResponse({"error": "invalid oauth state"}, status_code=400) + try: - session["docusign_access_token"] = save_code_token_exchange(code) + token_data = exchange_code_for_token(code) + session = session_from_token_data(token_data, session) 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") + session.pop("docusign_oauth_state", None) + session["docusign_auth_mode"] = "session_oauth" response = RedirectResponse("/") save_session(response, session) @@ -240,6 +278,11 @@ def docusign_disconnect(request: Request): session = get_session(request) session.pop("docusign_access_token", None) session.pop("docusign_refresh_token", None) + session.pop("docusign_token_expiry", None) + session.pop("docusign_oauth_state", None) + session.pop("docusign_user_name", None) + session.pop("docusign_user_email", None) + session["docusign_auth_mode"] = "disconnected" response = JSONResponse({"disconnected": "docusign"}) save_session(response, session) return response diff --git a/web/routers/migrate.py b/web/routers/migrate.py index 8437a7d..7be0b91 100644 --- a/web/routers/migrate.py +++ b/web/routers/migrate.py @@ -69,6 +69,20 @@ def _save_history(records: list) -> None: json.dump(records, f, indent=2) +def _session_scope(session: dict) -> str: + return session.get("_session_id") or "legacy" + + +def _scope_record(record: dict, session_scope: str) -> dict: + scoped = dict(record) + scoped["owner_session_id"] = session_scope + return scoped + + +def _filter_history_for_session(records: list, session_scope: str) -> list: + return [record for record in records if record.get("owner_session_id", "legacy") == session_scope] + + def _load_compose(): """Dynamically load compose_template from src/.""" import importlib.util @@ -341,6 +355,7 @@ async def run_migration(body: MigrateRequest, request: Request): ids = body.resolved_ids() if not ids: return JSONResponse({"error": "no template IDs provided"}, status_code=400) + session_scope = _session_scope(session) tasks = [ _migrate_one( @@ -352,18 +367,20 @@ async def run_migration(body: MigrateRequest, request: Request): for aid in ids ] results = await asyncio.gather(*tasks) + scoped_results = [_scope_record(result, session_scope) for result in results] history = _load_history() - history.extend(results) + history.extend(scoped_results) _save_history(history) - return {"results": list(results)} + return {"results": list(scoped_results)} @router.get("/history") -def migration_history(): +def migration_history(request: Request): """Return all past migration records.""" - return {"history": _load_history()} + session_scope = _session_scope(get_session(request)) + return {"history": _filter_history_for_session(_load_history(), session_scope)} # --------------------------------------------------------------------------- @@ -372,6 +389,7 @@ def migration_history(): async def _run_batch_job( job_id: str, + owner_session_id: str, ids: List[str], adobe_token: str, ds_token: str, @@ -397,7 +415,7 @@ async def _run_batch_job( # Persist to history history = _load_history() - history.extend(results) + history.extend(_scope_record(result, owner_session_id) for result in results) _save_history(history) success = sum(1 for r in results if r["status"] == "success") @@ -431,10 +449,12 @@ async def run_batch_migration(body: MigrateRequest, request: Request): ids = body.resolved_ids() if not ids: return JSONResponse({"error": "no template IDs provided"}, status_code=400) + session_scope = _session_scope(session) job_id = str(uuid.uuid4()) _batch_jobs[job_id] = { "job_id": job_id, + "owner_session_id": session_scope, "status": "queued", "total": len(ids), "results": [], @@ -445,7 +465,7 @@ async def run_batch_migration(body: MigrateRequest, request: Request): asyncio.create_task( _run_batch_job( - job_id, ids, + job_id, session_scope, ids, session["adobe_access_token"], session["docusign_access_token"], body.options, @@ -456,9 +476,12 @@ async def run_batch_migration(body: MigrateRequest, request: Request): @router.get("/batch/{job_id}") -def get_batch_status(job_id: str): +def get_batch_status(job_id: str, request: Request): """Poll the status of a batch migration job.""" job = _batch_jobs.get(job_id) if not job: return JSONResponse({"error": "batch job not found"}, status_code=404) + session_scope = _session_scope(get_session(request)) + if job.get("owner_session_id") != session_scope: + return JSONResponse({"error": "batch job not found"}, status_code=404) return job diff --git a/web/session.py b/web/session.py index 7a55cc4..f33f022 100644 --- a/web/session.py +++ b/web/session.py @@ -1,45 +1,161 @@ """ web/session.py -------------- -Session helpers using signed cookies (itsdangerous). -Stores Adobe Sign and DocuSign tokens server-side in the cookie payload. +Session helpers backed by a signed session-id cookie plus server-side JSON files. -Sessions are short-lived (1 hour) and signed but not encrypted. -Do not store sensitive secrets here beyond access tokens. +This keeps OAuth refresh tokens off the client and allows multiple testers to use +their own browser sessions concurrently against the same deployment. + +Backward compatibility: +- Older signed-cookie payloads that stored the full session dict are still read. +- Any write upgrades the browser to the server-side session-store format. """ +from __future__ import annotations + +import json +import os +import secrets +from typing import Any + from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired from fastapi import Request, Response + from web.config import settings _serializer = URLSafeTimedSerializer(settings.session_secret_key) _COOKIE_NAME = "migrator_session" _MAX_AGE = 3600 # 1 hour +_SESSION_ID_KEY = "_session_id" -def get_session(request: Request) -> dict: - """Read and verify the session cookie. Returns an empty dict if missing or invalid.""" +def _session_store_dir() -> str: + return settings.session_store_dir + + +def _session_path(session_id: str) -> str: + return os.path.join(_session_store_dir(), f"{session_id}.json") + + +def _ensure_store_dir() -> None: + os.makedirs(_session_store_dir(), exist_ok=True) + + +def _new_session_id() -> str: + return secrets.token_urlsafe(24) + + +def _load_server_session(session_id: str) -> dict: + path = _session_path(session_id) + if not os.path.exists(path): + return {} + try: + with open(path) as f: + data = json.load(f) + except (OSError, json.JSONDecodeError): + return {} + return data if isinstance(data, dict) else {} + + +def _write_server_session(session_id: str, data: dict) -> None: + _ensure_store_dir() + path = _session_path(session_id) + tmp_path = f"{path}.tmp" + with open(tmp_path, "w") as f: + json.dump(data, f) + os.replace(tmp_path, path) + + +def _delete_server_session(session_id: str | None) -> None: + if not session_id: + return + path = _session_path(session_id) + try: + os.remove(path) + except FileNotFoundError: + pass + + +def _attach_session_id(data: dict, session_id: str) -> dict: + payload = dict(data) + payload[_SESSION_ID_KEY] = session_id + return payload + + +def _decode_cookie(request: Request) -> dict | str | None: raw = request.cookies.get(_COOKIE_NAME) if not raw: - return {} + return None try: return _serializer.loads(raw, max_age=_MAX_AGE) except (BadSignature, SignatureExpired): + return None + + +def get_session(request: Request) -> dict: + """Return session data for the current request.""" + decoded = _decode_cookie(request) + if decoded is None: return {} + if isinstance(decoded, dict): + # Legacy cookie format from earlier app versions. + return decoded + if isinstance(decoded, str): + return _attach_session_id(_load_server_session(decoded), decoded) + return {} -def save_session(response: Response, data: dict) -> None: - """Sign and write session data into a cookie on the response.""" - signed = _serializer.dumps(data) +def get_session_id(request: Request) -> str | None: + session = get_session(request) + return session.get(_SESSION_ID_KEY) + + +def create_test_session(data: dict, session_id: str | None = None) -> str: + """ + Test helper: create a server-side session and return a valid cookie value. + """ + sid = session_id or _new_session_id() + payload = dict(data) + payload.pop(_SESSION_ID_KEY, None) + _write_server_session(sid, payload) + return _serializer.dumps(sid) + + +def save_session(response: Response, data: dict, session_id: str | None = None) -> str: + """Persist session data server-side and set the signed session-id cookie.""" + sid = session_id or data.get(_SESSION_ID_KEY) or _new_session_id() + payload = dict(data) + payload.pop(_SESSION_ID_KEY, None) + _write_server_session(sid, payload) response.set_cookie( _COOKIE_NAME, - signed, + _serializer.dumps(sid), max_age=_MAX_AGE, httponly=True, samesite="lax", ) + return sid -def clear_session(response: Response) -> None: - """Delete the session cookie.""" +def clear_session(response: Response, request: Request | None = None) -> None: + """Delete the browser session cookie and remove server-side session data when possible.""" + session_id = None + if request is not None: + session_id = get_session_id(request) + _delete_server_session(session_id) response.delete_cookie(_COOKIE_NAME) + + +def session_public_view(session: dict[str, Any]) -> dict[str, Any]: + """ + Return the subset of session data that is useful for UI/debug responses. + """ + return { + "session_id": session.get(_SESSION_ID_KEY), + "adobe": bool(session.get("adobe_access_token")), + "docusign": bool(session.get("docusign_access_token")), + "adobe_auth_mode": session.get("adobe_auth_mode", "disconnected"), + "docusign_auth_mode": session.get("docusign_auth_mode", "disconnected"), + "docusign_user_name": session.get("docusign_user_name"), + "docusign_user_email": session.get("docusign_user_email"), + } diff --git a/web/static/js/settings.js b/web/static/js/settings.js index f228b69..238afbd 100644 --- a/web/static/js/settings.js +++ b/web/static/js/settings.js @@ -167,6 +167,21 @@ async function _loadConnInfo() { ${data.docusign ? '● Connected' : '○ Disconnected'} +
+ Adobe Auth Mode + ${escHtml(data.adobe_auth_mode || '—')} + +
+
+ Docusign Auth Mode + ${escHtml(data.docusign_auth_mode || '—')} + +
+
+ Browser Session ID + ${escHtml(data.session_id || '—')} + +
Docusign Account ID ${escHtml(data.docusign_account_id || '—')}