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_ACCESS_TOKEN=
DOCUSIGN_REFRESH_TOKEN= DOCUSIGN_REFRESH_TOKEN=
DOCUSIGN_TOKEN_EXPIRY= 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 *.b64
downloads/ downloads/
migration-output/ migration-output/
.session-store/
*.pdf *.pdf
private.key 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. Opens a browser. After authorizing, paste the redirect URL back into the terminal.
Tokens are saved to `.env` and auto-refreshed on subsequent runs. Tokens are saved to `.env` and auto-refreshed on subsequent runs.
**4. Authorize DocuSign** (one-time per user): **4. Authorize DocuSign** (CLI, one-time per machine/user):
```bash ```bash
python3 src/docusign_auth.py --authorize 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:** **Additional `.env` keys required for the web UI:**
``` ```
SESSION_SECRET_KEY=<any random string> SESSION_SECRET_KEY=<any random string>
SESSION_STORE_DIR=/absolute/path/for/browser-session-files
DOCUSIGN_CLIENT_SECRET=<your DocuSign app client secret> DOCUSIGN_CLIENT_SECRET=<your DocuSign app client secret>
DOCUSIGN_REDIRECT_URI=http://localhost:8000/api/auth/docusign/callback DOCUSIGN_REDIRECT_URI=http://localhost:8000/api/auth/docusign/callback
ADOBE_REDIRECT_URI=http://localhost:8000/api/auth/adobe/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. 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 ### Navigation
| Screen | Path | Purpose | | 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. 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. 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: 3. **Review templates** — the Templates view shows readiness badges:
- **Ready** (green) — no issues, safe to migrate - **Ready** (green) — no issues, safe to migrate
- **Caveats** (amber) — warnings exist; migration will proceed but check Issues view - **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 # 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. This is the short version of the deployment process.
Use this when you already know what you are doing and just need the commands. 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 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 ## 5. Pull latest code on VM
@ -204,4 +211,5 @@ ssh ubuntu@dstemplate.mooo.com '
- Run tests first - Run tests first
- Commit before deploy - Commit before deploy
- Dont overwrite `.env`, `.env-adobe`, or `private.key` casually - 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` - 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 # 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: This document explains:
1. the **current live deployment setup** on the Oracle VM 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
/home/ubuntu/projects/adobe-to-docusign-migrator/.env-adobe /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/private.key
/home/ubuntu/projects/adobe-to-docusign-migrator/.session-store/
``` ```
These should **not** be overwritten casually. These should **not** be overwritten casually.
@ -131,6 +132,7 @@ They likely contain:
- DocuSign credentials - DocuSign credentials
- refresh tokens / secrets - refresh tokens / secrets
- app configuration - app configuration
- active browser-session files for concurrent testers
Before a risky deploy, back them up. Before a risky deploy, back them up.
@ -159,13 +161,26 @@ The deployed copy currently exists at:
``` ```
### Important note ### Important note
At the time this doc was written: Current expected deployment flow:
- local workspace branch: `ui-redesign` - local workspace deployment branch: `master`
- Oracle VM branch: `master` - Oracle VM deployment branch: `master`
So deployment should be done intentionally. So deployment should be done intentionally.
Do not assume the VM is following your current local branch automatically. 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 ## 6. Standard Deployment Procedure
@ -458,8 +473,9 @@ Use this carefully. Git-based deployment is cleaner.
3. **Commit before deploy.** 3. **Commit before deploy.**
4. **Prefer Git pull on the VM over manual file copying.** 4. **Prefer Git pull on the VM over manual file copying.**
5. **Do not overwrite `.env`, `.env-adobe`, or `private.key` unless intended.** 5. **Do not overwrite `.env`, `.env-adobe`, or `private.key` unless intended.**
6. **Restart the systemd service after code changes.** 6. **Do not casually delete `.session-store/` during active testing.**
7. **Smoke test both localhost and the public URL.** 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 return access_token
def build_authorization_url() -> str: def build_authorization_url(state: str | None = None) -> str:
client_id = _required_env("DOCUSIGN_CLIENT_ID") client_id = _required_env("DOCUSIGN_CLIENT_ID")
params = { params = {
"response_type": "code", "response_type": "code",
@ -78,6 +78,8 @@ def build_authorization_url() -> str:
"client_id": client_id, "client_id": client_id,
"redirect_uri": _redirect_uri(), "redirect_uri": _redirect_uri(),
} }
if state:
params["state"] = state
return f"https://{_auth_server()}/oauth/auth?{urlencode(params)}" 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) 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: def get_access_token() -> str:
"""Return a valid DocuSign access token using cached or refreshed OAuth tokens.""" """Return a valid DocuSign access token using cached or refreshed OAuth tokens."""
cached_token = os.getenv("DOCUSIGN_ACCESS_TOKEN") 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.app import app
from web.routers.auth import _ADOBE_TOKEN_URL from web.routers.auth import _ADOBE_TOKEN_URL
from web.session import create_test_session
client = TestClient(app, raise_server_exceptions=True) 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(): def test_status_unauthenticated():
"""Fresh session → both platforms disconnected.""" """Fresh session → both platforms disconnected."""
resp = client.get("/api/auth/status", cookies={}) resp = client.get("/api/auth/status", cookies={})
@ -96,11 +106,12 @@ def test_adobe_exchange_rejects_missing_code():
def test_docusign_connect_stores_token(): def test_docusign_connect_stores_token():
"""GET /api/auth/docusign/connect uses cached OAuth credentials → session connected.""" """GET /api/auth/docusign/connect refreshes the current session's token."""
from unittest.mock import patch from unittest.mock import patch
with patch("docusign_auth.get_access_token", return_value="ds-oauth-token"): cookie = create_test_session({"docusign_refresh_token": "refresh-123"})
resp = client.get("/api/auth/docusign/connect") 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.status_code == 200
assert resp.json()["connected"] is True 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(): 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 from unittest.mock import patch
with patch("docusign_auth.get_access_token", side_effect=RuntimeError("No DocuSign refresh token found")), \ with patch("docusign_auth.build_authorization_url", return_value="https://example.com/oauth"):
patch("docusign_auth.build_authorization_url", return_value="https://example.com/oauth"):
resp = client.get("/api/auth/docusign/connect") resp = client.get("/api/auth/docusign/connect")
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.json()["authorization_required"] is True assert resp.json()["authorization_required"] is True
assert resp.json()["authorization_url"] == "https://example.com/oauth" 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 @respx.mock

View File

@ -28,6 +28,10 @@ class Settings:
# Session # Session
session_secret_key: str = os.getenv("SESSION_SECRET_KEY", "dev-secret-change-in-production") 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 # App
version: str = "2.0" 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. 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 from urllib.parse import urlparse, parse_qs
import httpx import httpx
@ -20,7 +21,7 @@ from fastapi.responses import JSONResponse, RedirectResponse
from pydantic import BaseModel from pydantic import BaseModel
from web.config import settings 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() router = APIRouter()
@ -39,8 +40,11 @@ def auth_status(request: Request):
"""Returns which platforms the current session is connected to.""" """Returns which platforms the current session is connected to."""
session = get_session(request) session = get_session(request)
return { return {
"adobe": bool(session.get("adobe_access_token")), **session_public_view(session),
"docusign": bool(session.get("docusign_access_token")), "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 = get_session(request)
session["adobe_access_token"] = token_data.get("access_token") session["adobe_access_token"] = token_data.get("access_token")
session["adobe_refresh_token"] = token_data.get("refresh_token") session["adobe_refresh_token"] = token_data.get("refresh_token")
session["adobe_auth_mode"] = "session_oauth"
response = JSONResponse({"connected": True}) response = JSONResponse({"connected": True})
save_session(response, session) save_session(response, session)
@ -147,6 +152,7 @@ def adobe_connect_env(request: Request):
session = get_session(request) session = get_session(request)
session["adobe_access_token"] = token session["adobe_access_token"] = token
session["adobe_refresh_token"] = refresh_token session["adobe_refresh_token"] = refresh_token
session["adobe_auth_mode"] = "shared_env"
response = JSONResponse({"connected": True}) response = JSONResponse({"connected": True})
save_session(response, session) save_session(response, session)
@ -158,6 +164,7 @@ def adobe_disconnect(request: Request):
session = get_session(request) session = get_session(request)
session.pop("adobe_access_token", None) session.pop("adobe_access_token", None)
session.pop("adobe_refresh_token", None) session.pop("adobe_refresh_token", None)
session["adobe_auth_mode"] = "disconnected"
response = JSONResponse({"disconnected": "adobe"}) response = JSONResponse({"disconnected": "adobe"})
save_session(response, session) save_session(response, session)
return response return response
@ -170,31 +177,49 @@ def adobe_disconnect(request: Request):
@router.get("/docusign/connect") @router.get("/docusign/connect")
def docusign_connect(request: Request): def docusign_connect(request: Request):
""" """
Obtain a DocuSign access token from cached OAuth credentials in .env. Obtain a DocuSign access token from the current browser session.
If the app has not been authorized yet, return the authorization URL so the If the session has not been authorized yet, return an authorization URL so
frontend can start the browser flow. the frontend can start an isolated OAuth flow for this tester.
""" """
import sys import sys
import os import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src")) sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src"))
from docusign_auth import 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: 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: except RuntimeError as e:
if "refresh token" in str(e).lower(): 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, "connected": False,
"authorization_required": True, "authorization_required": True,
"authorization_url": build_authorization_url(), "authorization_url": build_authorization_url(state=state),
}, },
status_code=200, status_code=200,
) )
save_session(response, session)
return response
return JSONResponse({"error": str(e)}, status_code=500) return JSONResponse({"error": str(e)}, status_code=500)
session = get_session(request)
session["docusign_access_token"] = token session["docusign_access_token"] = token
session["docusign_auth_mode"] = "session_oauth"
response = JSONResponse({"connected": True}) response = JSONResponse({"connected": True})
save_session(response, session) save_session(response, session)
@ -202,33 +227,46 @@ def docusign_connect(request: Request):
@router.get("/docusign/start") @router.get("/docusign/start")
def docusign_start(): def docusign_start(request: Request):
"""Redirect to the DocuSign OAuth authorization screen.""" """Redirect to the DocuSign OAuth authorization screen for this browser session."""
import sys import sys
import os import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src")) sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src"))
from docusign_auth import build_authorization_url 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") @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.""" """Handle DocuSign OAuth redirect callback."""
import sys import sys
import os import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src")) sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src"))
from docusign_auth import save_code_token_exchange from docusign_auth import exchange_code_for_token, session_from_token_data
if not code: if not code:
return JSONResponse({"error": "missing code"}, status_code=400) return JSONResponse({"error": "missing code"}, status_code=400)
session = get_session(request) 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: 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: except Exception as e:
return JSONResponse({"error": "token exchange failed", "detail": str(e)}, status_code=502) 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("/") response = RedirectResponse("/")
save_session(response, session) save_session(response, session)
@ -240,6 +278,11 @@ def docusign_disconnect(request: Request):
session = get_session(request) session = get_session(request)
session.pop("docusign_access_token", None) session.pop("docusign_access_token", None)
session.pop("docusign_refresh_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"}) response = JSONResponse({"disconnected": "docusign"})
save_session(response, session) save_session(response, session)
return response return response

View File

@ -69,6 +69,20 @@ def _save_history(records: list) -> None:
json.dump(records, f, indent=2) 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(): def _load_compose():
"""Dynamically load compose_template from src/.""" """Dynamically load compose_template from src/."""
import importlib.util import importlib.util
@ -341,6 +355,7 @@ async def run_migration(body: MigrateRequest, request: Request):
ids = body.resolved_ids() ids = body.resolved_ids()
if not ids: if not ids:
return JSONResponse({"error": "no template IDs provided"}, status_code=400) return JSONResponse({"error": "no template IDs provided"}, status_code=400)
session_scope = _session_scope(session)
tasks = [ tasks = [
_migrate_one( _migrate_one(
@ -352,18 +367,20 @@ async def run_migration(body: MigrateRequest, request: Request):
for aid in ids for aid in ids
] ]
results = await asyncio.gather(*tasks) results = await asyncio.gather(*tasks)
scoped_results = [_scope_record(result, session_scope) for result in results]
history = _load_history() history = _load_history()
history.extend(results) history.extend(scoped_results)
_save_history(history) _save_history(history)
return {"results": list(results)} return {"results": list(scoped_results)}
@router.get("/history") @router.get("/history")
def migration_history(): def migration_history(request: Request):
"""Return all past migration records.""" """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( async def _run_batch_job(
job_id: str, job_id: str,
owner_session_id: str,
ids: List[str], ids: List[str],
adobe_token: str, adobe_token: str,
ds_token: str, ds_token: str,
@ -397,7 +415,7 @@ async def _run_batch_job(
# Persist to history # Persist to history
history = _load_history() history = _load_history()
history.extend(results) history.extend(_scope_record(result, owner_session_id) for result in results)
_save_history(history) _save_history(history)
success = sum(1 for r in results if r["status"] == "success") 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() ids = body.resolved_ids()
if not ids: if not ids:
return JSONResponse({"error": "no template IDs provided"}, status_code=400) return JSONResponse({"error": "no template IDs provided"}, status_code=400)
session_scope = _session_scope(session)
job_id = str(uuid.uuid4()) job_id = str(uuid.uuid4())
_batch_jobs[job_id] = { _batch_jobs[job_id] = {
"job_id": job_id, "job_id": job_id,
"owner_session_id": session_scope,
"status": "queued", "status": "queued",
"total": len(ids), "total": len(ids),
"results": [], "results": [],
@ -445,7 +465,7 @@ async def run_batch_migration(body: MigrateRequest, request: Request):
asyncio.create_task( asyncio.create_task(
_run_batch_job( _run_batch_job(
job_id, ids, job_id, session_scope, ids,
session["adobe_access_token"], session["adobe_access_token"],
session["docusign_access_token"], session["docusign_access_token"],
body.options, body.options,
@ -456,9 +476,12 @@ async def run_batch_migration(body: MigrateRequest, request: Request):
@router.get("/batch/{job_id}") @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.""" """Poll the status of a batch migration job."""
job = _batch_jobs.get(job_id) job = _batch_jobs.get(job_id)
if not job: if not job:
return JSONResponse({"error": "batch job not found"}, status_code=404) 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 return job

View File

@ -1,45 +1,161 @@
""" """
web/session.py web/session.py
-------------- --------------
Session helpers using signed cookies (itsdangerous). Session helpers backed by a signed session-id cookie plus server-side JSON files.
Stores Adobe Sign and DocuSign tokens server-side in the cookie payload.
Sessions are short-lived (1 hour) and signed but not encrypted. This keeps OAuth refresh tokens off the client and allows multiple testers to use
Do not store sensitive secrets here beyond access tokens. 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 itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
from fastapi import Request, Response from fastapi import Request, Response
from web.config import settings from web.config import settings
_serializer = URLSafeTimedSerializer(settings.session_secret_key) _serializer = URLSafeTimedSerializer(settings.session_secret_key)
_COOKIE_NAME = "migrator_session" _COOKIE_NAME = "migrator_session"
_MAX_AGE = 3600 # 1 hour _MAX_AGE = 3600 # 1 hour
_SESSION_ID_KEY = "_session_id"
def get_session(request: Request) -> dict: def _session_store_dir() -> str:
"""Read and verify the session cookie. Returns an empty dict if missing or invalid.""" 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) raw = request.cookies.get(_COOKIE_NAME)
if not raw: if not raw:
return {} return None
try: try:
return _serializer.loads(raw, max_age=_MAX_AGE) return _serializer.loads(raw, max_age=_MAX_AGE)
except (BadSignature, SignatureExpired): 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 {} return {}
def save_session(response: Response, data: dict) -> None: def get_session_id(request: Request) -> str | None:
"""Sign and write session data into a cookie on the response.""" session = get_session(request)
signed = _serializer.dumps(data) 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( response.set_cookie(
_COOKIE_NAME, _COOKIE_NAME,
signed, _serializer.dumps(sid),
max_age=_MAX_AGE, max_age=_MAX_AGE,
httponly=True, httponly=True,
samesite="lax", samesite="lax",
) )
return sid
def clear_session(response: Response) -> None: def clear_session(response: Response, request: Request | None = None) -> None:
"""Delete the session cookie.""" """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) 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 class="badge ${data.docusign ? 'badge-green' : 'badge-gray'}">${data.docusign ? '● Connected' : '○ Disconnected'}</span>
</span> </span>
</div> </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"> <div class="conn-info-row">
<span class="conn-info-label">Docusign Account ID</span> <span class="conn-info-label">Docusign Account ID</span>
<span class="conn-info-value mono">${escHtml(data.docusign_account_id || '—')}</span> <span class="conn-info-value mono">${escHtml(data.docusign_account_id || '—')}</span>