From 39357263d01c03bc65c9bfe531e953c0c21a579f Mon Sep 17 00:00:00 2001 From: timmy Date: Sat, 8 Aug 2026 03:41:30 +0000 Subject: [PATCH] feat: require operator sessions for privileged access (#258) --- README.md | 26 +++- frontend/index.html | 2 + frontend/service-worker.js | 24 ++- frontend/session.js | 92 +++++++++++ src/dashboard_auth.py | 102 +++++++++++++ src/main.py | 141 +++++++++++++++-- src/views.py | 13 ++ tests/test_dashboard_auth.py | 186 +++++++++++++++++++++++ tests/test_dashboard_session_frontend.py | 81 ++++++++++ tests/test_service_worker.py | 13 +- 10 files changed, 655 insertions(+), 25 deletions(-) create mode 100644 frontend/session.js create mode 100644 src/dashboard_auth.py create mode 100644 tests/test_dashboard_auth.py create mode 100644 tests/test_dashboard_session_frontend.py diff --git a/README.md b/README.md index a91ee70..35652d7 100644 --- a/README.md +++ b/README.md @@ -44,18 +44,34 @@ head-scoped draft comments anchored to changed lines; the dashboard validates ea comment path and submits the summary, decision, and inline comments in one review request. The dashboard rechecks the current pull-request head, CI success, draft state, and mergeability immediately before every merge. -Serve the dashboard -only to trusted users on its own origin; cross-origin API -access is intentionally disabled. Then start the API and bundled frontend: +Serve the dashboard only to trusted users on its own origin; cross-origin API +access is intentionally disabled. For any deployment not already behind an +authenticated gateway, configure the built-in single-operator boundary with two +independent high-entropy secrets. The access token is entered at `/login`; the +browser receives only a short-lived signed session and CSRF proof. Generate the +values in your secret manager (for example, `openssl rand -hex 32`) and inject +them at runtime—never commit them: ```bash export GITEA_URL='https://forge.example.com' export GITEA_TOKEN='' +export STACKCHAIN_DASHBOARD_ACCESS_TOKEN='' +export STACKCHAIN_DASHBOARD_SESSION_SECRET='' +# Optional; defaults to eight hours. +export STACKCHAIN_DASHBOARD_SESSION_TTL_SECONDS=28800 uvicorn src.main:app --host 127.0.0.1 --port 8000 ``` -Open `http://127.0.0.1:8000/` for the dashboard. To verify the backend and its -Gitea connection directly, request +Terminate TLS at the trusted reverse proxy: session cookies are deliberately +`Secure`, `HttpOnly`, `SameSite=Strict`, and scoped to the deployment subpath. +Use **Sign out & clear this device** on shared devices; it clears Stackchain's +offline snapshots, drafts, outboxes, background IndexedDB, and PWA caches without +removing unrelated forge preferences. Rotate either dashboard secret by replacing +the injected value and restarting the service; changing the signing secret expires +all sessions immediately. + +Open `http://127.0.0.1:8000/` only for an unprotected local development run. To +verify the backend and its Gitea connection directly, sign in and request `http://127.0.0.1:8000/api/v1/context`; a successful response is JSON containing `user`, `repos`, `issues`, and `pull_requests`. Press `Ctrl/Cmd+K` in the dashboard to search commands plus issues and pull requests across every repository visible to diff --git a/frontend/index.html b/frontend/index.html index 74625ed..42f81db 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -292,6 +292,7 @@ textarea { resize: vertical; min-height: 120px; }
Live
+
@@ -747,6 +748,7 @@ textarea { resize: vertical; min-height: 120px; } + diff --git a/frontend/service-worker.js b/frontend/service-worker.js index fbd115d..98d072f 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -1,12 +1,13 @@ const BASE = new URL('./', self.location.href).pathname; importScripts(BASE + 'static/background-issue-sync.js'); -const CACHE = 'stackchain-dashboard-shell-v15'; +const CACHE = 'stackchain-dashboard-shell-v16'; const OUTAGE_STATUSES = new Set([500, 502, 503, 504]); const SHELL = [ BASE, BASE + 'manifest.webmanifest', BASE + 'static/icons/stackchain-192.png', BASE + 'static/icons/stackchain-512.png', + BASE + 'static/session.js', BASE + 'static/markdown.js', BASE + 'static/commands.js', BASE + 'static/search-preview.js', @@ -31,8 +32,25 @@ const SHELL = [ BASE + 'static/background-issue-sync.js', ]; -async function fetchJson(url, options) { - const response = await fetch(new URL(url, self.location.origin), options); +async function sessionCsrf() { + const response = await fetch(new URL(BASE + 'api/v1/session', self.location.origin), { + headers: { Accept: 'application/json' }, + }); + if (!response.ok) return ''; + const payload = await response.json().catch(() => ({})); + return typeof payload.csrf_token === 'string' ? payload.csrf_token : ''; +} + +async function fetchJson(url, options = {}) { + const requestOptions = { ...options }; + const method = String(options.method || 'GET').toUpperCase(); + if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) { + const headers = new Headers(options.headers || {}); + const csrf = await sessionCsrf(); + if (csrf) headers.set('X-CSRF-Token', csrf); + requestOptions.headers = headers; + } + const response = await fetch(new URL(url, self.location.origin), requestOptions); const payload = await response.json().catch(() => ({})); if (!response.ok) { const error = new Error(payload.error || payload.detail || 'Background issue delivery failed.'); diff --git a/frontend/session.js b/frontend/session.js new file mode 100644 index 0000000..11eb04a --- /dev/null +++ b/frontend/session.js @@ -0,0 +1,92 @@ +(function (root, factory) { + if (typeof module !== 'undefined' && module.exports) module.exports = factory; + else { + const base = new URL('./', root.location.href).pathname; + const originalFetch = root.fetch.bind(root); + const boundary = factory({ + cookie: () => root.document.cookie, + origin: root.location.origin, + base, + fetchImpl: originalFetch, + localStorage: root.localStorage, + sessionStorage: root.sessionStorage, + indexedDB: root.indexedDB, + caches: root.caches, + location: root.location, + onExpired: () => root.dispatchEvent(new CustomEvent('stackchain:session-expired')), + }); + root.fetch = boundary.fetch; + const attach = () => { + const button = root.document.getElementById('sign-out'); + if (button) button.addEventListener('click', () => boundary.signOut()); + }; + if (root.document.readyState === 'loading') root.document.addEventListener('DOMContentLoaded', attach); + else attach(); + root.stackchainSession = boundary; + } +})(typeof window !== 'undefined' ? window : this, function createSessionBoundary({ + cookie, origin, base, fetchImpl, localStorage, sessionStorage, indexedDB, caches, location, + onExpired = () => {}, +}) { + const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); + + function csrfToken() { + const entry = String(cookie?.() || '').split(';') + .map(value => value.trim()) + .find(value => value.startsWith('stackchain_csrf=')); + return entry ? decodeURIComponent(entry.slice('stackchain_csrf='.length)) : ''; + } + + function isSameOrigin(input) { + try { return new URL(String(input?.url || input), origin).origin === origin; } + catch (_error) { return false; } + } + + async function sessionFetch(input, options = {}) { + const method = String(options.method || input?.method || 'GET').toUpperCase(); + const requestOptions = { ...options }; + if (!SAFE_METHODS.has(method) && isSameOrigin(input)) { + const headers = new Headers(options.headers || input?.headers || {}); + const csrf = csrfToken(); + if (csrf) headers.set('X-CSRF-Token', csrf); + requestOptions.headers = headers; + } + const response = await fetchImpl(input, requestOptions); + if (response.status === 401) onExpired(); + return response; + } + + function removeDashboardStorage(storage) { + if (!storage) return; + const keys = []; + try { + for (let index = 0; index < storage.length; index += 1) { + const key = storage.key(index); + if (key?.startsWith('stackchain.')) keys.push(key); + } + keys.forEach(key => storage.removeItem(key)); + } catch (_error) { /* Cookie invalidation still protects server data. */ } + } + + async function clearPrivateDeviceData() { + removeDashboardStorage(localStorage); + if (sessionStorage !== localStorage) removeDashboardStorage(sessionStorage); + try { indexedDB?.deleteDatabase('stackchain-background-outbox-v1'); } + catch (_error) { /* Continue clearing other dashboard state. */ } + try { + const keys = await caches?.keys?.() || []; + await Promise.all(keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key))); + } catch (_error) { /* A later service-worker activation can clear stale caches. */ } + } + + async function signOut() { + try { + await sessionFetch(base + 'api/v1/session', { method: 'DELETE' }); + } finally { + await clearPrivateDeviceData(); + location.assign(base + 'login'); + } + } + + return { fetch: sessionFetch, signOut, clearPrivateDeviceData }; +}); diff --git a/src/dashboard_auth.py b/src/dashboard_auth.py new file mode 100644 index 0000000..6645b29 --- /dev/null +++ b/src/dashboard_auth.py @@ -0,0 +1,102 @@ +"""Signed, short-lived single-operator sessions for the dashboard boundary.""" + +import base64 +import hashlib +import hmac +import json +import os +import secrets +import time +from dataclasses import dataclass + +from fastapi import Request + +SESSION_COOKIE = "stackchain_session" +CSRF_COOKIE = "stackchain_csrf" +DEFAULT_TTL_SECONDS = 8 * 60 * 60 + + +@dataclass(frozen=True) +class Session: + csrf: str + expires_at: int + + +def access_token() -> str: + return os.getenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "") + + +def enabled() -> bool: + return bool(access_token()) + + +def _secret() -> bytes: + configured = os.getenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "") + if configured: + return configured.encode() + return hmac.new( + access_token().encode(), b"stackchain-dashboard-session-signing", hashlib.sha256 + ).digest() + + +def _encode(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode() + + +def _decode(value: str) -> bytes: + return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) + + +def issue_session(now: int | None = None) -> tuple[str, Session]: + issued_at = int(time.time() if now is None else now) + ttl = int(os.getenv("STACKCHAIN_DASHBOARD_SESSION_TTL_SECONDS", str(DEFAULT_TTL_SECONDS))) + session = Session(csrf=secrets.token_urlsafe(24), expires_at=issued_at + max(1, ttl)) + payload = json.dumps( + {"csrf": session.csrf, "exp": session.expires_at}, + separators=(",", ":"), + sort_keys=True, + ).encode() + encoded = _encode(payload) + signature = _encode(hmac.new(_secret(), encoded.encode(), hashlib.sha256).digest()) + return f"{encoded}.{signature}", session + + +def verify_session(value: str | None, now: int | None = None) -> Session | None: + if not value or "." not in value or not enabled(): + return None + encoded, supplied_signature = value.rsplit(".", 1) + expected = _encode(hmac.new(_secret(), encoded.encode(), hashlib.sha256).digest()) + if not hmac.compare_digest(supplied_signature, expected): + return None + try: + payload = json.loads(_decode(encoded)) + session = Session(csrf=payload["csrf"], expires_at=int(payload["exp"])) + except (ValueError, TypeError, KeyError, json.JSONDecodeError): + return None + current = int(time.time() if now is None else now) + if session.expires_at <= current or not isinstance(session.csrf, str) or not session.csrf: + return None + return session + + +def request_session(request: Request) -> Session | None: + return verify_session(request.cookies.get(SESSION_COOKIE)) + + +def cookie_path(request: Request) -> str: + root_path = request.scope.get("root_path", "").rstrip("/") + return root_path or "/" + + +def application_path(request: Request) -> str: + path = request.url.path + root_path = request.scope.get("root_path", "").rstrip("/") + if root_path and (path == root_path or path.startswith(root_path + "/")): + return path[len(root_path):] or "/" + return path + + +def same_origin(request: Request) -> bool: + origin = request.headers.get("origin", "") + expected = f"{request.url.scheme}://{request.url.netloc}" + return bool(origin) and hmac.compare_digest(origin.rstrip("/"), expected) diff --git a/src/main.py b/src/main.py index 3e236bc..fcd60fa 100644 --- a/src/main.py +++ b/src/main.py @@ -1,4 +1,5 @@ import asyncio +import hmac import math import os import time @@ -8,12 +9,12 @@ from datetime import datetime from pathlib import Path from typing import Any, Literal -from fastapi import FastAPI, Header, HTTPException, Path as PathParam, Query -from fastapi.responses import JSONResponse +from fastapi import FastAPI, Header, HTTPException, Path as PathParam, Query, Request, Response +from fastapi.responses import JSONResponse, RedirectResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel, Field, PositiveInt, field_validator, model_validator -from src import gitea_proxy +from src import dashboard_auth, gitea_proxy from src.gitea_proxy import ( activity_events, current_user, @@ -124,6 +125,10 @@ class ReadinessPayloadError(ValueError): """Raised when Gitea returns a structurally invalid readiness payload.""" +class DashboardSignIn(BaseModel): + access_token: str = Field(min_length=1, max_length=1_024) + + class NotificationReadBatch(BaseModel): ids: list[PositiveInt] = Field(min_length=1, max_length=50) @@ -423,21 +428,63 @@ app.mount("/static", StaticFiles(directory=FRONTEND_DIR), name="static") app.include_router(frontend_router) +@app.middleware("http") +async def require_operator_session(request: Request, call_next): + if not dashboard_auth.enabled(): + return await call_next(request) + + path = dashboard_auth.application_path(request) + public = ( + path in {"/healthz", "/readyz", "/login", "/manifest.webmanifest"} + or path.startswith("/static/") + or (path == "/api/v1/session" and request.method == "POST") + ) + session = dashboard_auth.request_session(request) + if not public and session is None: + if path.startswith("/api/"): + return JSONResponse( + {"detail": "Authentication required"}, + status_code=401, + headers={"Cache-Control": "no-store"}, + ) + return RedirectResponse("login", status_code=303, headers={"Cache-Control": "no-store"}) + + if not public and request.method not in {"GET", "HEAD", "OPTIONS"}: + supplied_csrf = request.headers.get("x-csrf-token", "") + if ( + session is None + or not dashboard_auth.same_origin(request) + or not supplied_csrf + or not hmac.compare_digest(supplied_csrf, session.csrf) + ): + return JSONResponse( + {"detail": "Valid same-origin CSRF proof required"}, + status_code=403, + headers={"Cache-Control": "no-store"}, + ) + request.state.dashboard_session = session + response = await call_next(request) + if path in {"/login", "/api/v1/session"}: + response.headers["Cache-Control"] = "no-store" + return response + + @app.middleware("http") async def prevent_live_api_caching(request, call_next): response = await call_next(request) - if request.url.path in {"/api/v1/context", "/api/v1/background-identity", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search", "/api/v1/work-route"} or request.url.path.startswith("/api/v1/work/") or ( - request.url.path.startswith("/api/v1/repos/") - and request.url.path.endswith("/review") - ) or request.url.path.startswith("/api/v1/notifications") or ( - request.url.path.startswith("/api/v1/repos/") - and ("/issues/" in request.url.path or "/pulls/" in request.url.path) + path = dashboard_auth.application_path(request) + if path in {"/api/v1/context", "/api/v1/background-identity", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search", "/api/v1/work-route"} or path.startswith("/api/v1/work/") or ( + path.startswith("/api/v1/repos/") + and path.endswith("/review") + ) or path.startswith("/api/v1/notifications") or ( + path.startswith("/api/v1/repos/") + and ("/issues/" in path or "/pulls/" in path) ) or ( - request.url.path.startswith("/api/v1/repos/") - and request.url.path.endswith("/issues") + path.startswith("/api/v1/repos/") + and path.endswith("/issues") ) or ( - request.url.path.startswith("/api/v1/repos/") - and request.url.path.endswith(("/labels", "/milestones")) + path.startswith("/api/v1/repos/") + and path.endswith(("/labels", "/milestones")) ): response.headers["Cache-Control"] = "no-store" return response @@ -449,6 +496,68 @@ def health() -> dict[str, str]: return {"status": "ok", "service": "stackchain-dashboard"} +@app.post("/api/v1/session") +async def sign_in(payload: DashboardSignIn, request: Request, response: Response): + configured_token = dashboard_auth.access_token() + if not configured_token or not hmac.compare_digest( + payload.access_token, configured_token + ): + raise HTTPException(status_code=401, detail="Invalid access token") + signed, session = dashboard_auth.issue_session() + path = dashboard_auth.cookie_path(request) + max_age = max(1, session.expires_at - int(time.time())) + response.set_cookie( + dashboard_auth.SESSION_COOKIE, + signed, + max_age=max_age, + path=path, + secure=True, + httponly=True, + samesite="strict", + ) + response.set_cookie( + dashboard_auth.CSRF_COOKIE, + session.csrf, + max_age=max_age, + path=path, + secure=True, + httponly=False, + samesite="strict", + ) + response.headers["Cache-Control"] = "no-store" + return {"authenticated": True} + + +@app.get("/api/v1/session") +async def session_status(request: Request): + session = dashboard_auth.request_session(request) + return { + "authenticated": session is not None, + "csrf_token": session.csrf if session is not None else "", + } + + +@app.delete("/api/v1/session") +async def sign_out(request: Request, response: Response): + path = dashboard_auth.cookie_path(request) + response.delete_cookie( + dashboard_auth.SESSION_COOKIE, + path=path, + secure=True, + httponly=True, + samesite="strict", + ) + response.delete_cookie( + dashboard_auth.CSRF_COOKIE, + path=path, + secure=True, + httponly=False, + samesite="strict", + ) + response.headers["Cache-Control"] = "no-store" + return {"authenticated": False, "clear_private_device_data": True} + + @app.get("/readyz") async def readiness(): """Return readiness after verifying the configured Gitea connection.""" @@ -482,11 +591,13 @@ async def readiness(): "Retry-After": str(max(1, math.ceil(READINESS_TIMEOUT_SECONDS))) } if timed_out else None, ) - return { + payload = { "status": "ready", "service": "stackchain-dashboard", - "gitea_user": user["login"], } + if not dashboard_auth.enabled(): + payload["gitea_user"] = user["login"] + return payload @app.get("/api/v1/context") diff --git a/src/views.py b/src/views.py index 357ec7e..8db9179 100644 --- a/src/views.py +++ b/src/views.py @@ -8,6 +8,14 @@ DASHBOARD_FILE = Path(__file__).resolve().parent.parent / "frontend" / "index.ht MANIFEST_FILE = DASHBOARD_FILE.parent / "manifest.webmanifest" SERVICE_WORKER_FILE = DASHBOARD_FILE.parent / "service-worker.js" +LOGIN_HTML = """ + +Sign in · Stackchain Dashboard + +

Operator sign in

Enter the dashboard access token. It is exchanged for a private, short-lived session and is never stored on this device.

+

+""" + class RevalidatingHTMLResponse(HTMLResponse): def __init__(self, content, status_code=200, headers=None, media_type=None, background=None): @@ -21,6 +29,11 @@ async def dashboard() -> str: return DASHBOARD_FILE.read_text() +@router.get("/login", response_class=RevalidatingHTMLResponse) +async def login() -> str: + return LOGIN_HTML + + @router.get("/manifest.webmanifest", response_class=FileResponse) async def web_app_manifest() -> FileResponse: return FileResponse(MANIFEST_FILE, media_type="application/manifest+json") diff --git a/tests/test_dashboard_auth.py b/tests/test_dashboard_auth.py new file mode 100644 index 0000000..3c7124a --- /dev/null +++ b/tests/test_dashboard_auth.py @@ -0,0 +1,186 @@ +import httpx +import pytest + +from src import main + + +@pytest.fixture +def access_control(monkeypatch): + monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple") + monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "a-separate-session-signing-secret-with-enough-entropy") + + +@pytest.mark.anyio +async def test_anonymous_private_request_is_rejected_before_gitea(access_control, monkeypatch): + called = False + + async def user(): + nonlocal called + called = True + return {"id": 1, "login": "timmy"} + + monkeypatch.setattr(main, "current_user", user) + transport = httpx.ASGITransport(app=main.app) + async with httpx.AsyncClient(transport=transport, base_url="https://test") as client: + response = await client.get("/api/v1/context") + + assert response.status_code == 401 + assert response.json() == {"detail": "Authentication required"} + assert response.headers["cache-control"] == "no-store" + assert called is False + + +@pytest.mark.anyio +async def test_sign_in_creates_secure_session_without_echoing_access_token(access_control): + transport = httpx.ASGITransport(app=main.app) + async with httpx.AsyncClient(transport=transport, base_url="https://test") as client: + response = await client.post( + "/api/v1/session", json={"access_token": "correct horse battery staple"} + ) + + assert response.status_code == 200 + assert response.json() == {"authenticated": True} + cookies = response.headers.get_list("set-cookie") + assert any("stackchain_session=" in value and "HttpOnly" in value and "Secure" in value and "SameSite=strict" in value for value in cookies) + assert any("stackchain_csrf=" in value and "Secure" in value and "SameSite=strict" in value and "HttpOnly" not in value for value in cookies) + assert "correct horse battery staple" not in response.text + assert response.headers["cache-control"] == "no-store" + + +@pytest.mark.anyio +async def test_authenticated_get_reaches_private_api(access_control, monkeypatch): + async def user(): + return {"id": 1, "login": "timmy", "full_name": "", "email": ""} + + async def empty(): + return [] + + monkeypatch.setattr(main, "current_user", user) + monkeypatch.setattr(main, "repos", empty) + monkeypatch.setattr(main, "issues", empty) + monkeypatch.setattr(main, "pull_requests", empty) + transport = httpx.ASGITransport(app=main.app) + async with httpx.AsyncClient(transport=transport, base_url="https://test") as client: + signed_in = await client.post( + "/api/v1/session", json={"access_token": "correct horse battery staple"} + ) + response = await client.get("/api/v1/context") + + assert signed_in.status_code == 200 + assert response.status_code == 200 + assert response.json()["user"]["login"] == "timmy" + + +@pytest.mark.anyio +async def test_authenticated_session_status_exposes_only_csrf_proof(access_control): + transport = httpx.ASGITransport(app=main.app) + async with httpx.AsyncClient(transport=transport, base_url="https://test") as client: + await client.post( + "/api/v1/session", json={"access_token": "correct horse battery staple"} + ) + response = await client.get("/api/v1/session") + + assert response.status_code == 200 + assert response.json() == { + "authenticated": True, + "csrf_token": client.cookies["stackchain_csrf"], + } + assert "correct horse battery staple" not in response.text + + +@pytest.mark.anyio +async def test_mutation_requires_same_origin_and_session_csrf(access_control, monkeypatch): + calls = 0 + + async def mark_read(notification_id): + nonlocal calls + calls += 1 + return {"id": notification_id, "read": True} + + monkeypatch.setattr(main, "mark_notification_read", mark_read) + transport = httpx.ASGITransport(app=main.app) + async with httpx.AsyncClient(transport=transport, base_url="https://test") as client: + await client.post( + "/api/v1/session", json={"access_token": "correct horse battery staple"} + ) + missing = await client.patch("/api/v1/notifications/7/read") + foreign = await client.patch( + "/api/v1/notifications/7/read", + headers={ + "Origin": "https://evil.example", + "X-CSRF-Token": client.cookies["stackchain_csrf"], + }, + ) + valid = await client.patch( + "/api/v1/notifications/7/read", + headers={ + "Origin": "https://test", + "X-CSRF-Token": client.cookies["stackchain_csrf"], + }, + ) + + assert missing.status_code == 403 + assert foreign.status_code == 403 + assert valid.status_code == 200 + assert calls == 1 + + +@pytest.mark.anyio +async def test_logout_clears_session_and_blocks_private_routes(access_control): + transport = httpx.ASGITransport(app=main.app) + async with httpx.AsyncClient(transport=transport, base_url="https://test") as client: + await client.post( + "/api/v1/session", json={"access_token": "correct horse battery staple"} + ) + csrf = client.cookies["stackchain_csrf"] + response = await client.delete( + "/api/v1/session", + headers={"Origin": "https://test", "X-CSRF-Token": csrf}, + ) + private = await client.get("/api/v1/background-identity") + + assert response.status_code == 200 + assert response.json() == {"authenticated": False, "clear_private_device_data": True} + assert private.status_code == 401 + assert all("Max-Age=0" in value for value in response.headers.get_list("set-cookie")) + + +@pytest.mark.anyio +async def test_public_routes_remain_available_and_readiness_hides_identity(access_control, monkeypatch): + async def user(): + return {"id": 1, "login": "timmy"} + + monkeypatch.setattr(main, "current_user", user) + transport = httpx.ASGITransport(app=main.app) + async with httpx.AsyncClient(transport=transport, base_url="https://test") as client: + health = await client.get("/healthz") + login = await client.get("/login") + ready = await client.get("/readyz") + + assert health.status_code == 200 + assert login.status_code == 200 + assert 'name="access_token"' in login.text + assert ready.status_code == 200 + assert ready.json() == {"status": "ready", "service": "stackchain-dashboard"} + + +@pytest.mark.anyio +async def test_subpath_deployment_scopes_routes_and_session_cookies(access_control): + transport = httpx.ASGITransport(app=main.app, root_path="/dashboard") + async with httpx.AsyncClient( + transport=transport, base_url="https://test/dashboard/" + ) as client: + login = await client.get("login") + signed_in = await client.post( + "api/v1/session", + json={"access_token": "correct horse battery staple"}, + ) + status = await client.get("api/v1/session") + + assert login.status_code == 200 + assert signed_in.status_code == 200 + assert status.status_code == 200 + assert any( + "stackchain_session=" in value and "Path=/dashboard" in value + for value in signed_in.headers.get_list("set-cookie") + ) diff --git a/tests/test_dashboard_session_frontend.py b/tests/test_dashboard_session_frontend.py new file mode 100644 index 0000000..1cc297c --- /dev/null +++ b/tests/test_dashboard_session_frontend.py @@ -0,0 +1,81 @@ +import json +import subprocess +from pathlib import Path + +import pytest + +from src.views import dashboard + + +ROOT = Path(__file__).resolve().parents[1] +SESSION_JS = ROOT / "frontend" / "session.js" + + +def run_session_scenario(scenario: str) -> dict: + harness = f""" +const createSessionBoundary = require({json.dumps(str(SESSION_JS))}); +const state = {{ requests: [], removed: [], deletedDatabases: [], deletedCaches: [], assigned: '' }}; +const storage = {{ + values: new Map([['stackchain.private', 'secret'], ['gitea.preference', 'keep']]), + get length() {{ return this.values.size; }}, + key(index) {{ return Array.from(this.values.keys())[index] || null; }}, + removeItem(key) {{ state.removed.push(key); this.values.delete(key); }}, +}}; +const boundary = createSessionBoundary({{ + cookie: () => 'other=x; stackchain_csrf=csrf-proof; theme=dark', + origin: 'https://forge.example', + base: '/dashboard/', + fetchImpl: async (url, options = {{}}) => {{ + state.requests.push({{ url: String(url), method: options.method || 'GET', headers: Object.fromEntries(new Headers(options.headers || {{}})) }}); + return new Response('{{}}', {{ status: 200, headers: {{ 'Content-Type': 'application/json' }} }}); + }}, + localStorage: storage, + sessionStorage: storage, + indexedDB: {{ deleteDatabase: name => {{ state.deletedDatabases.push(name); return {{ onsuccess: null, onerror: null, onblocked: null }}; }} }}, + caches: {{ keys: async () => ['stackchain-dashboard-shell-v15', 'gitea-assets'], delete: async key => {{ state.deletedCaches.push(key); }} }}, + location: {{ assign: value => {{ state.assigned = value; }} }}, +}}); +(async () => {{ {scenario} }})().catch(error => {{ console.error(error); process.exit(1); }}); +""" + result = subprocess.run(["node", "-e", harness], text=True, capture_output=True, check=True) + return json.loads(result.stdout) + + +def test_mutating_same_origin_fetch_receives_csrf_proof(): + result = run_session_scenario( + """ +await boundary.fetch('/dashboard/api/v1/notifications/7/read', { method: 'PATCH' }); +process.stdout.write(JSON.stringify(state)); +""" + ) + + assert result["requests"][0]["headers"]["x-csrf-token"] == "csrf-proof" + + +def test_sign_out_clears_only_dashboard_private_device_state(): + result = run_session_scenario( + """ +await boundary.signOut(); +state.remaining = Array.from(storage.values.keys()); +process.stdout.write(JSON.stringify(state)); +""" + ) + + request = result["requests"][0] + assert request["url"] == "/dashboard/api/v1/session" + assert request["method"] == "DELETE" + assert request["headers"]["x-csrf-token"] == "csrf-proof" + assert "stackchain.private" in result["removed"] + assert result["remaining"] == ["gitea.preference"] + assert result["deletedDatabases"] == ["stackchain-background-outbox-v1"] + assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"] + assert result["assigned"] == "/dashboard/login" + + +@pytest.mark.anyio +async def test_dashboard_loads_session_boundary_first_and_offers_sign_out(): + html = await dashboard() + + assert '' in html + assert html.index('static/session.js') < html.index('static/markdown.js') + assert '' in html diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index fd383a5..5e21b30 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -70,10 +70,10 @@ async function dispatchSync(tag) {{ return json.loads(completed.stdout) -def test_detail_defer_ships_in_a_new_shell_cache(): +def test_operator_session_boundary_ships_in_a_new_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v15" in source + assert "stackchain-dashboard-shell-v16" in source def test_background_sync_event_flushes_closed_app_issue_outbox_only_for_its_tag(): @@ -88,6 +88,14 @@ def test_background_sync_event_flushes_closed_app_issue_outbox_only_for_its_tag( assert result["backgroundFlushes"] == 1 +def test_background_mutations_obtain_session_bound_csrf_proof(): + source = WORKER.read_text() + + assert "async function sessionCsrf()" in source + assert "BASE + 'api/v1/session'" in source + assert "headers.set('X-CSRF-Token', csrf)" in source + + def test_install_precaches_complete_subpath_scoped_app_shell(): result = run_worker_scenario( """ @@ -103,6 +111,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell(): "/dashboard/manifest.webmanifest", "/dashboard/static/icons/stackchain-192.png", "/dashboard/static/icons/stackchain-512.png", + "/dashboard/static/session.js", "/dashboard/static/markdown.js", "/dashboard/static/commands.js", "/dashboard/static/search-preview.js",