Add Authentik/OIDC compose env vars and enforce proxy auth.

Document issuer, outpost, and header settings for Coolify, fail closed when AUTH_REQUIRED is true, and add harvester healthcheck per Coolify conventions.
This commit is contained in:
epistemophiliac 2026-06-19 00:55:12 -04:00
parent b5db15d6ab
commit 7f7133f535
5 changed files with 83 additions and 14 deletions

View file

@ -1,7 +1,17 @@
# Bugsink (Sentry-compatible DSN from bugsink.aexoradao.com project settings)
BUGSINK_DSN=
# Local dev only — simulates Authentik proxy header when not behind auth
# Authentik / OIDC (configured on Coolify proxy; app trusts forwarded headers)
AUTHENTIK_ISSUER=https://auth.aexoradao.com/application/o/quant-web/
OIDC_ISSUER=https://auth.aexoradao.com/application/o/quant-web/
AUTHENTIK_OUTPOST_URL=https://auth.aexoradao.com/outpost.goauthentik.io/auth/traefik
OIDC_CLIENT_ID=quant-web
AUTH_USERNAME_HEADER=X-Forwarded-User
AUTH_UID_HEADER=X-Authentik-Uid
AUTH_EMAIL_HEADER=X-Forwarded-Email
AUTH_REQUIRED=true
# Local dev only when AUTH_REQUIRED=false
DEV_USER=dev@local
# Core tickers (comma-separated)

View file

@ -18,21 +18,28 @@ Local-first quantitative backtesting on Coolify: Streamlit UI, VectorBT engine,
1. Create a **Docker Compose** resource pointing at this repo.
2. Assign your public domain to the **`streamlit`** service on port **8501**.
3. Set environment variables in Coolify (first deploy extracts defaults from compose):
3. Enable **Authentik forward auth** on that domain in Coolify (OIDC happens at the proxy; Streamlit never holds client secrets).
4. Set environment variables in Coolify (first deploy extracts defaults from compose):
- `BUGSINK_DSN` — DSN from your Bugsink project
- `AUTHENTIK_ISSUER`, `OIDC_ISSUER`, `AUTHENTIK_OUTPOST_URL`, `OIDC_CLIENT_ID` — match your Authentik application/outpost
- `AUTH_USERNAME_HEADER` — header Coolify/Traefik forwards after login (default `X-Forwarded-User`)
- `AUTH_REQUIRED` — keep `true` in production
- `CORE_TICKERS` — optional comma-separated tickers
- `DEV_USER` — only for unauthenticated local testing
4. Protect the domain in your reverse proxy with Authentik forward auth.
- `DEV_USER` — only when `AUTH_REQUIRED=false` for local testing
**Coolify note:** if you change any default above after the first deploy, update the value manually in Coolify UI > Environment Variables. Coolify stores first-seen defaults and will not auto-refresh them from compose.
### Authentik / proxy headers
After login, the proxy must forward one of these headers to Streamlit:
OIDC login is handled by Authentik + Coolify reverse proxy. The container does not run an OAuth code flow; it trusts identity headers injected after forward auth.
- `X-Forwarded-User` (recommended)
- `X-Authentik-Username`
- `Remote-User`
After login, the proxy must forward headers such as:
The app reads them via Streamlit websocket headers (`auth.get_current_user()`). Saved strategies are scoped to that username.
- `X-Forwarded-User` (default `AUTH_USERNAME_HEADER`)
- `X-Authentik-Uid` (`AUTH_UID_HEADER`)
- `X-Forwarded-Email` (`AUTH_EMAIL_HEADER`)
The app reads them via Streamlit websocket headers (`auth.get_current_user()`). With `AUTH_REQUIRED=true`, missing headers block the UI instead of falling back to anonymous.
Example Traefik middleware (adjust provider labels to your stack):

11
app.py
View file

@ -60,6 +60,17 @@ def render_equity_chart(result) -> None:
def main() -> None:
user = get_current_user()
if user is None:
st.error(
"Authentication required. Access this app through Authentik-protected "
"Coolify proxy so identity headers are forwarded."
)
st.caption(
"Expected header: "
f"`{os.environ.get('AUTH_USERNAME_HEADER', 'X-Forwarded-User')}` "
f"from `{os.environ.get('AUTHENTIK_ISSUER', 'https://auth.aexoradao.com')}`."
)
st.stop()
st.title("QuantTrade")
st.caption("VectorBT backtests on local Parquet market data")

35
auth.py
View file

@ -5,7 +5,7 @@ from __future__ import annotations
import os
from typing import Mapping
HEADER_CANDIDATES = (
DEFAULT_HEADER_CANDIDATES = (
"X-Forwarded-User",
"X-Authentik-Username",
"X-Authentik-Uid",
@ -14,6 +14,29 @@ HEADER_CANDIDATES = (
)
def _truthy(value: str | None, default: bool = False) -> bool:
if value is None:
return default
return value.strip().lower() in {"1", "true", "yes", "on"}
def auth_required() -> bool:
return _truthy(os.environ.get("AUTH_REQUIRED"), default=True)
def header_candidates() -> tuple[str, ...]:
primary = os.environ.get("AUTH_USERNAME_HEADER", "").strip()
extras = [
os.environ.get("AUTH_UID_HEADER", "").strip(),
os.environ.get("AUTH_EMAIL_HEADER", "").strip(),
]
ordered: list[str] = []
for name in (primary, *extras, *DEFAULT_HEADER_CANDIDATES):
if name and name not in ordered:
ordered.append(name)
return tuple(ordered)
def _normalize(value: str | None) -> str | None:
if not value:
return None
@ -23,14 +46,14 @@ def _normalize(value: str | None) -> str | None:
def username_from_headers(headers: Mapping[str, str]) -> str | None:
lowered = {k.lower(): v for k, v in headers.items()}
for name in HEADER_CANDIDATES:
for name in header_candidates():
value = _normalize(lowered.get(name.lower()))
if value:
return value
return None
def get_current_user() -> str:
def get_current_user() -> str | None:
"""Return the authenticated username from proxy-injected headers."""
try:
from streamlit.web.server.websocket_headers import _get_websocket_headers
@ -42,4 +65,8 @@ def get_current_user() -> str:
except Exception:
pass
return os.environ.get("DEV_USER", "anonymous")
if auth_required():
return None
dev_user = os.environ.get("DEV_USER", "").strip()
return dev_user or "anonymous"

View file

@ -41,6 +41,12 @@ services:
- 'TZ=America/New_York'
volumes:
- 'parquet-data:/data/parquet'
healthcheck:
test: ['CMD-SHELL', 'pgrep -x cron > /dev/null || exit 1']
interval: 60s
timeout: 5s
start_period: 30s
retries: 3
streamlit:
build:
@ -58,7 +64,15 @@ services:
- 'CORE_TICKERS=${CORE_TICKERS:-SPY,QQQ,AAPL,MSFT,GOOGL,AMZN,NVDA,META,IWM,TLT}'
- 'PARQUET_DIR=/data/parquet'
- 'STRATEGY_DB_PATH=/data/strategies/strategies.db'
- 'DEV_USER=${DEV_USER:-anonymous}'
- 'AUTHENTIK_ISSUER=${AUTHENTIK_ISSUER:-https://auth.aexoradao.com/application/o/quant-web/}'
- 'OIDC_ISSUER=${OIDC_ISSUER:-https://auth.aexoradao.com/application/o/quant-web/}'
- 'AUTHENTIK_OUTPOST_URL=${AUTHENTIK_OUTPOST_URL:-https://auth.aexoradao.com/outpost.goauthentik.io/auth/traefik}'
- 'OIDC_CLIENT_ID=${OIDC_CLIENT_ID:-quant-web}'
- 'AUTH_USERNAME_HEADER=${AUTH_USERNAME_HEADER:-X-Forwarded-User}'
- 'AUTH_UID_HEADER=${AUTH_UID_HEADER:-X-Authentik-Uid}'
- 'AUTH_EMAIL_HEADER=${AUTH_EMAIL_HEADER:-X-Forwarded-Email}'
- 'AUTH_REQUIRED=${AUTH_REQUIRED:-true}'
- 'DEV_USER=${DEV_USER:-}'
volumes:
- 'parquet-data:/data/parquet'
- 'strategy-data:/data/strategies'