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:
parent
b5db15d6ab
commit
7f7133f535
5 changed files with 83 additions and 14 deletions
12
.env.example
12
.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)
|
||||
|
|
|
|||
23
README.md
23
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):
|
||||
|
||||
|
|
|
|||
11
app.py
11
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")
|
||||
|
|
|
|||
35
auth.py
35
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"
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
Loading…
Reference in a new issue