feat: require operator sessions for privileged access (#258)
This commit is contained in:
parent
e40c75b360
commit
39357263d0
26
README.md
26
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='<read-notification-and-issue-write-token>'
|
||||
export STACKCHAIN_DASHBOARD_ACCESS_TOKEN='<operator-sign-in-secret>'
|
||||
export STACKCHAIN_DASHBOARD_SESSION_SECRET='<independent-cookie-signing-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
|
||||
|
|
|
|||
|
|
@ -292,6 +292,7 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
<div class="toolbar">
|
||||
<div class="status"><span class="dot"></span><span class="small" id="status">Live</span></div>
|
||||
<button id="refresh">Refresh</button>
|
||||
<button id="sign-out" type="button">Sign out & clear this device</button>
|
||||
<button id="open-palette">Command</button>
|
||||
<span class="small muted" id="clock"></span>
|
||||
</div>
|
||||
|
|
@ -747,6 +748,7 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
|
||||
<div class="footer">Creative AI-imbued UI • stackchain-dashboard</div>
|
||||
|
||||
<script src="static/session.js"></script>
|
||||
<script src="static/markdown.js"></script>
|
||||
<script src="static/commands.js"></script>
|
||||
<script src="static/search-preview.js"></script>
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
|
|
|
|||
92
frontend/session.js
Normal file
92
frontend/session.js
Normal file
|
|
@ -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 };
|
||||
});
|
||||
102
src/dashboard_auth.py
Normal file
102
src/dashboard_auth.py
Normal file
|
|
@ -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)
|
||||
141
src/main.py
141
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")
|
||||
|
|
|
|||
13
src/views.py
13
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 = """<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Sign in · Stackchain Dashboard</title>
|
||||
<style>body{margin:0;background:#07111f;color:#eef6ff;font:16px system-ui;display:grid;min-height:100vh;place-items:center}main{width:min(90vw,24rem);padding:2rem;border:1px solid #29415d;border-radius:1rem;background:#0d1b2b}label,input,button{display:block;width:100%;box-sizing:border-box}input,button{min-height:48px;margin-top:.6rem;border-radius:.6rem;border:1px solid #49647f;padding:.75rem}button{margin-top:1rem;background:#55d6be;color:#06121b;font-weight:700}p{color:#a9bed3}</style></head>
|
||||
<body><main><h1>Operator sign in</h1><p>Enter the dashboard access token. It is exchanged for a private, short-lived session and is never stored on this device.</p>
|
||||
<form id="sign-in"><label>Access token<input name="access_token" type="password" autocomplete="current-password" required></label><button>Sign in</button><p id="status" role="status"></p></form></main>
|
||||
<script>document.getElementById('sign-in').addEventListener('submit',async(event)=>{event.preventDefault();const status=document.getElementById('status');status.textContent='Signing in…';const access_token=new FormData(event.currentTarget).get('access_token');const response=await fetch('api/v1/session',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({access_token})});if(response.ok){location.replace('./');return}status.textContent='Sign-in failed. Check the token and try again.';event.currentTarget.reset()});</script></body></html>"""
|
||||
|
||||
|
||||
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")
|
||||
|
|
|
|||
186
tests/test_dashboard_auth.py
Normal file
186
tests/test_dashboard_auth.py
Normal file
|
|
@ -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")
|
||||
)
|
||||
81
tests/test_dashboard_session_frontend.py
Normal file
81
tests/test_dashboard_session_frontend.py
Normal file
|
|
@ -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 '<script src="static/session.js"></script>' in html
|
||||
assert html.index('static/session.js') < html.index('static/markdown.js')
|
||||
assert '<button id="sign-out" type="button">Sign out & clear this device</button>' in html
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user