Add multi-user web auth sessions

This commit is contained in:
Paul Huliganga 2026-04-21 21:05:15 -04:00
parent b8dbad73ac
commit eb9ce84001
12 changed files with 405 additions and 54 deletions

View File

@ -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=

1
.gitignore vendored
View File

@ -9,5 +9,6 @@ __pycache__/
*.b64
downloads/
migration-output/
.session-store/
*.pdf
private.key

View File

@ -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=<any random string>
SESSION_STORE_DIR=/absolute/path/for/browser-session-files
DOCUSIGN_CLIENT_SECRET=<your DocuSign app 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

View File

@ -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
- Dont overwrite `.env`, `.env-adobe`, or `private.key` casually
- Dont casually delete `.session-store/` while testers are active
- If the site breaks, check `journalctl -u adobe-migrator`

View File

@ -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.**
---

View File

@ -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")

View File

@ -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

View File

@ -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"

View File

@ -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

View File

@ -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

View File

@ -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"),
}

View File

@ -167,6 +167,21 @@ async function _loadConnInfo() {
<span class="badge ${data.docusign ? 'badge-green' : 'badge-gray'}">${data.docusign ? '● Connected' : '○ Disconnected'}</span>
</span>
</div>
<div class="conn-info-row">
<span class="conn-info-label">Adobe Auth Mode</span>
<span class="conn-info-value mono">${escHtml(data.adobe_auth_mode || '—')}</span>
<span class="conn-info-status"></span>
</div>
<div class="conn-info-row">
<span class="conn-info-label">Docusign Auth Mode</span>
<span class="conn-info-value mono">${escHtml(data.docusign_auth_mode || '—')}</span>
<span class="conn-info-status"></span>
</div>
<div class="conn-info-row">
<span class="conn-info-label">Browser Session ID</span>
<span class="conn-info-value mono">${escHtml(data.session_id || '—')}</span>
<span class="conn-info-status"></span>
</div>
<div class="conn-info-row">
<span class="conn-info-label">Docusign Account ID</span>
<span class="conn-info-value mono">${escHtml(data.docusign_account_id || '—')}</span>