Add multi-user web auth sessions
This commit is contained in:
parent
b8dbad73ac
commit
eb9ce84001
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -9,5 +9,6 @@ __pycache__/
|
|||
*.b64
|
||||
downloads/
|
||||
migration-output/
|
||||
.session-store/
|
||||
*.pdf
|
||||
private.key
|
||||
|
|
|
|||
21
README.md
21
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=<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
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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.**
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
142
web/session.py
142
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"),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue