From 7f7133f535c2ef4d6c23b9d328eda03ab1092f30 Mon Sep 17 00:00:00 2001 From: epistemophiliac Date: Fri, 19 Jun 2026 00:55:12 -0400 Subject: [PATCH] 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. --- .env.example | 12 +++++++++++- README.md | 23 +++++++++++++++-------- app.py | 11 +++++++++++ auth.py | 35 +++++++++++++++++++++++++++++++---- docker-compose.yml | 16 +++++++++++++++- 5 files changed, 83 insertions(+), 14 deletions(-) diff --git a/.env.example b/.env.example index 9a2b5e5..04edde2 100644 --- a/.env.example +++ b/.env.example @@ -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) diff --git a/README.md b/README.md index 2c83390..206dfaf 100644 --- a/README.md +++ b/README.md @@ -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): diff --git a/app.py b/app.py index 3011983..b3646d3 100644 --- a/app.py +++ b/app.py @@ -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") diff --git a/auth.py b/auth.py index e7f297f..af26c1c 100644 --- a/auth.py +++ b/auth.py @@ -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" diff --git a/docker-compose.yml b/docker-compose.yml index 07592e5..4fcb973 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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'